From 60ecc86bc5d5c8cb2e39395c89f05bb075e2ddb3 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 26 Oct 2026 18:31:42 -0700 Subject: [PATCH 001/158] fix(mobile): scale native text with system font settings --- apps/mobile/global.css | 2 + .../t3composereditor/T3ComposerEditorView.kt | 2 +- .../ios/T3ComposerEditorView.swift | 4 +- .../T3NativeControlsModule.kt | 11 ++ .../ios/T3NativeControlsModule.swift | 1 + .../ios/T3SegmentedControlView.swift | 6 +- .../t3reviewdiff/ReviewDiffCanvasDrawing.kt | 28 +++++ .../modules/t3reviewdiff/T3ReviewDiffView.kt | 41 +++---- .../t3-review-diff/ios/T3ReviewDiffView.swift | 42 +++++-- .../ios/T3SourceTextShadowNode.mm | 2 +- .../modules/t3-source-text/src/SourceText.tsx | 81 +++++++++++--- .../expo/modules/t3terminal/T3TerminalView.kt | 9 +- .../modules/t3terminal/TerminalCanvasView.kt | 7 +- .../src/components/CompactBrandTitle.tsx | 5 +- .../components/ComposerContextAttachment.tsx | 2 +- .../src/components/ComposerContextSheet.tsx | 14 ++- apps/mobile/src/components/FilePreview.tsx | 30 ++--- .../src/components/NewThreadButton.shared.tsx | 2 +- apps/mobile/src/components/ui/Button.tsx | 1 + .../src/components/ui/Dialog.android.tsx | 8 +- .../src/components/ui/MenuPopup.android.tsx | 8 +- apps/mobile/src/components/ui/NumberField.tsx | 9 +- .../components/ui/SegmentedControl.ios.tsx | 13 ++- .../src/components/ui/SegmentedControl.tsx | 10 +- .../src/components/ui/useToolbarLayout.ts | 10 +- .../features/browser/PreviewStreamWebView.tsx | 2 +- .../connection/ConnectionsNewRouteScreen.tsx | 6 +- .../development/ComponentCatalogScreen.tsx | 2 +- .../NativeAppBarExample.android.tsx | 4 +- .../src/features/files/AudioFilePreview.tsx | 4 +- .../features/files/FileMarkdownPreview.tsx | 6 +- .../src/features/files/SourceFileSurface.tsx | 5 + .../src/features/keyboard/CommandPalette.tsx | 2 +- .../PullRequestDiscussionScreen.tsx | 2 +- .../pull-requests/PullRequestListRow.tsx | 2 +- .../PullRequestReviewersScreen.tsx | 4 +- .../pull-requests/PullRequestRouteScreen.tsx | 2 +- .../pull-requests/guide/GuideNotice.tsx | 2 +- .../pull-requests/guide/GuideOutline.tsx | 2 +- .../guide/PullRequestGuideScreen.tsx | 30 +++-- .../src/features/review/ReviewCommentCard.tsx | 3 + .../review/ReviewCommentComposerSheet.tsx | 12 +- .../src/features/review/ReviewSheet.tsx | 8 +- .../review/nativeReviewDiffAdapter.ts | 27 +++-- .../features/review/reviewDiffRendering.tsx | 9 +- .../SettingsNavigationRouteScreen.tsx | 2 +- .../SettingsOpenSourceLicensesRouteScreen.tsx | 4 +- .../SettingsProviderAccountsRouteScreen.tsx | 10 +- .../settings/appearance/systemFontMetrics.ts | 51 +++++++++ .../appearance/useAppearanceCodeSurface.ts | 23 +++- .../terminal/NativeTerminalSurface.test.tsx | 6 +- .../terminal/NativeTerminalSurface.tsx | 16 ++- .../threads/CustomSnoozeSheet.android.tsx | 26 +++-- .../threads/CustomSnoozeSheet.ios.tsx | 51 ++++++--- .../threads/CustomSnoozeSheet.shared.tsx | 14 ++- .../threads/LinkedPullRequestList.tsx | 8 +- .../threads/NewTaskDraftRouteScreen.tsx | 4 +- .../features/threads/NewTaskDraftScreen.tsx | 2 +- .../features/threads/PendingApprovalCard.tsx | 2 +- .../features/threads/PendingUserInputCard.tsx | 4 +- .../features/threads/SecretRequestCard.tsx | 2 +- .../src/features/threads/ThreadComposer.tsx | 9 +- .../threads/ThreadNavigationSidebar.tsx | 2 +- .../threads/ThreadSettingsRows.shared.tsx | 2 +- .../features/threads/worktree-setup-card.tsx | 4 +- .../src/features/usage/UsageRouteScreen.tsx | 2 +- .../voice-input/ComposerDictationControl.tsx | 2 +- .../AndroidWorkspaceNavigation.android.tsx | 2 +- .../workspace/WorkspaceListToolbarRow.tsx | 8 +- .../src/lib/appearancePreferences.test.ts | 47 +++++++- apps/mobile/src/lib/appearancePreferences.ts | 1 + apps/mobile/src/lib/typography.ts | 2 + apps/mobile/src/lib/useSystemFontScale.ts | 17 +++ .../src/native/T3ComposerEditor.ios.tsx | 14 ++- .../src/native/T3ComposerEditor.native.tsx | 17 +-- apps/mobile/src/native/V5StackHeader.ios.tsx | 11 +- ...native-enriched-markdown@1.2.0-t3.16.patch | 30 +++++ .../react-native-screens@5.0.0-t3.45.patch | 103 ++++++++++++++++++ pnpm-lock.yaml | 16 +-- pnpm-workspace.yaml | 2 + 80 files changed, 755 insertions(+), 243 deletions(-) create mode 100644 apps/mobile/src/features/settings/appearance/systemFontMetrics.ts create mode 100644 apps/mobile/src/lib/useSystemFontScale.ts create mode 100644 patches/react-native-enriched-markdown@1.2.0-t3.16.patch create mode 100644 patches/react-native-screens@5.0.0-t3.45.patch diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 730b9e1a97f3..dab291381bf7 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -37,6 +37,8 @@ --text-2xl--line-height: 32px; --text-3xl: 30px; --text-3xl--line-height: 36px; + --text-4xl: 36px; + --text-4xl--line-height: 40px; } /* ─── Custom utilities ──────────────────────────────────────────────── */ diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ecc37df17c99..86ad564d2da9 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -277,7 +277,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } fun setFontSize(fontSize: Float) { - editor.textSize = fontSize + editor.setTextSize(TypedValue.COMPLEX_UNIT_DIP, fontSize) applyLineHeight() applyTokenSpans() editor.applyPlaceholder() diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index f54f75caf5f5..6f1a4b0ce6b1 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -472,7 +472,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.keyboardDismissMode = .interactive textView.alwaysBounceVertical = false textView.showsVerticalScrollIndicator = true - textView.adjustsFontForContentSizeCategory = true + textView.adjustsFontForContentSizeCategory = false textView.onPasteImages = { [weak self] urls in self?.onComposerPasteImages(["uris": urls]) } @@ -510,7 +510,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro addSubview(textView) placeholderLabel.numberOfLines = 0 - placeholderLabel.adjustsFontForContentSizeCategory = true + placeholderLabel.adjustsFontForContentSizeCategory = false addSubview(placeholderLabel) applyTypography() applyTheme() diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 7a5e11319288..d8f062402337 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -2,6 +2,7 @@ package expo.modules.t3nativecontrols import android.content.Intent import android.text.format.DateFormat +import android.util.TypedValue import androidx.core.content.FileProvider import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module @@ -21,6 +22,16 @@ class T3NativeControlsModule : Module() { DateFormat.is24HourFormat(context) } + Function("scaledFontSize") { size: Double -> + val context = appContext.reactContext ?: error("The app is not active.") + val metrics = context.resources.displayMetrics + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + size.toFloat(), + metrics + ) / metrics.density + } + AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> check(filePreviewPromise == null) { "A document viewer is already open." } val activity = appContext.currentActivity ?: error("The app is not active.") diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index a5b8f7a44e3a..70cc195fa562 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -23,6 +23,7 @@ public final class T3NativeControlsModule: Module { ViewName("SegmentedControl") Events("onSelectionChange") Prop("labels") { (view: T3SegmentedControlView, value: [String]) in view.labels = value } + Prop("fontSize") { (view: T3SegmentedControlView, value: Double) in view.fontSize = CGFloat(value) } Prop("selectedIndex") { (view: T3SegmentedControlView, value: Int) in view.selectedIndex = value } Prop("trackColor") { (view: T3SegmentedControlView, value: UIColor) in view.trackColor = value } Prop("selectedColor") { (view: T3SegmentedControlView, value: UIColor) in view.selectedColor = value } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift index d8d5abaa1407..509f0337a019 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SegmentedControlView.swift @@ -14,6 +14,7 @@ final class T3SegmentedControlView: ExpoView { updateSelection() } } + var fontSize: CGFloat = 13 { didSet { if fontSize != oldValue { updateColors() } } } var selectedIndex = -1 { didSet { updateSelection() } } var trackColor: UIColor = .secondarySystemBackground { didSet { if trackColor != oldValue { control.backgroundColor = trackColor } } } var selectedColor: UIColor = .secondarySystemFill { didSet { if selectedColor != oldValue { control.selectedSegmentTintColor = selectedColor } } } @@ -30,6 +31,7 @@ final class T3SegmentedControlView: ExpoView { super.init(appContext: appContext) addSubview(control) control.addTarget(self, action: #selector(selectionChanged), for: .valueChanged) + updateColors() } override func layoutSubviews() { @@ -51,8 +53,8 @@ final class T3SegmentedControlView: ExpoView { } private func updateColors() { - control.setTitleTextAttributes([.foregroundColor: mutedColor], for: .normal) - control.setTitleTextAttributes([.foregroundColor: textColor], for: .selected) + control.setTitleTextAttributes([.foregroundColor: mutedColor, .font: UIFont.systemFont(ofSize: fontSize, weight: .medium)], for: .normal) + control.setTitleTextAttributes([.foregroundColor: textColor, .font: UIFont.systemFont(ofSize: fontSize, weight: .semibold)], for: .selected) } @objc private func selectionChanged() { diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 7a1225cd392b..0ade237f7776 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -43,6 +43,34 @@ internal class ReviewDiffCanvasDrawing(context: Context) { uiPaint.typeface = Typeface.DEFAULT_BOLD } + fun commentTextMetrics(style: DiffStyle): Paint.FontMetrics { + configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + return textPaint.fontMetrics + } + + fun commentTitleBaseline(metrics: Paint.FontMetrics): Float = + max(22f * density, 8f * density - metrics.ascent) + + fun commentBodyBaseline(metrics: Paint.FontMetrics): Float = + commentTitleBaseline(metrics) + + max(36f * density, metrics.descent - metrics.ascent + 12f * density) + + fun commentRowHeight(style: DiffStyle, collapsed: Boolean): Int { + val metrics = commentTextMetrics(style) + return ceil( + if (collapsed) { + max(44f * density, commentTitleBaseline(metrics) + metrics.descent + 18f * density) + } else { + max(124f * density, commentBodyBaseline(metrics) + metrics.descent + 24f * density) + }, + ).toInt() + } + fun fileHeaderChevronRect(top: Int, bottom: Int, style: DiffStyle): RectF { val centerY = (top + bottom) / 2f val left = style.fileHeaderHorizontalPaddingPx diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 8426ddb565a4..70f139f66423 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -678,9 +678,9 @@ internal data class DiffStyle( fallback.fileHeaderHorizontalPaddingPx, density, ), - codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density), + codeFontSizePx = json.floatDp("codeFontSize", fallback.codeFontSizePx, density), codeFontWeight = json.optString("codeFontWeight", fallback.codeFontWeight), - lineNumberFontSizePx = json.floatSp( + lineNumberFontSizePx = json.floatDp( "lineNumberFontSize", fallback.lineNumberFontSizePx, density, @@ -689,9 +689,9 @@ internal data class DiffStyle( "lineNumberFontWeight", fallback.lineNumberFontWeight, ), - hunkFontSizePx = json.floatSp("hunkFontSize", fallback.hunkFontSizePx, density), + hunkFontSizePx = json.floatDp("hunkFontSize", fallback.hunkFontSizePx, density), hunkFontWeight = json.optString("hunkFontWeight", fallback.hunkFontWeight), - fileHeaderFontSizePx = json.floatSp( + fileHeaderFontSizePx = json.floatDp( "fileHeaderFontSize", fallback.fileHeaderFontSizePx, density, @@ -700,7 +700,7 @@ internal data class DiffStyle( "fileHeaderFontWeight", fallback.fileHeaderFontWeight, ), - fileHeaderMetaFontSizePx = json.floatSp( + fileHeaderMetaFontSizePx = json.floatDp( "fileHeaderMetaFontSize", fallback.fileHeaderMetaFontSizePx, density, @@ -709,7 +709,7 @@ internal data class DiffStyle( "fileHeaderMetaFontWeight", fallback.fileHeaderMetaFontWeight, ), - fileHeaderSubtextFontSizePx = json.floatSp( + fileHeaderSubtextFontSizePx = json.floatDp( "fileHeaderSubtextFontSize", fallback.fileHeaderSubtextFontSizePx, density, @@ -977,11 +977,7 @@ private class DiffCanvasView(context: Context) : View(context) { private fun rowHeight(row: DiffRow): Int = when (row.kind) { "file" -> style.fileHeaderHeightPx.toInt() "notice" -> max((style.rowHeightPx * 2f).toInt(), (44 * density).toInt()) - "comment" -> if (collapsedCommentIds.contains(row.id)) { - (44 * density).toInt() - } else { - (124 * density).toInt() - } + "comment" -> drawing.commentRowHeight(style, collapsedCommentIds.contains(row.id)) "line" -> codeWrap.rowHeight(row, style.rowHeightPx.toInt()) else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -1108,6 +1104,10 @@ private class DiffCanvasView(context: Context) : View(context) { val pathX = iconRect.right + 10f * density val pathWidth = max(24f * density, countsX - pathX - 12f * density) val centerY = (top + bottom) / 2f + val pathHeight = max( + 20f * density, + ceil(boldTextPaint.fontMetrics.descent - boldTextPaint.fontMetrics.ascent), + ) val displayPath = if ( !row.previousPath.isNullOrEmpty() && row.previousPath != row.filePath ) { @@ -1117,7 +1117,7 @@ private class DiffCanvasView(context: Context) : View(context) { } return FileHeaderPathLayout( displayPath = displayPath, - rect = RectF(pathX, centerY - 10f * density, pathX + pathWidth, centerY + 10f * density), + rect = RectF(pathX, centerY - pathHeight / 2f, pathX + pathWidth, centerY + pathHeight / 2f), ) } @@ -1223,11 +1223,17 @@ private class DiffCanvasView(context: Context) : View(context) { borderPaint.style = Paint.Style.FILL val collapsed = collapsedCommentIds.contains(row.id) + val metrics = drawing.commentTextMetrics(style) + val titleBaseline = drawing.commentTitleBaseline(metrics) + val chevronTop = max( + 11f * density, + titleBaseline + (metrics.ascent + metrics.descent) / 2f - 8f * density + ) val chevronRect = RectF( cardRect.left + 10f * density, - cardRect.top + 11f * density, + cardRect.top + chevronTop, cardRect.left + 26f * density, - cardRect.top + 27f * density, + cardRect.top + chevronTop + 16f * density, ) drawing.drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed) drawing.configureUiPaint( @@ -1240,7 +1246,7 @@ private class DiffCanvasView(context: Context) : View(context) { canvas.drawText( ellipsize(title, textPaint, cardRect.right - chevronRect.right - 20f * density), chevronRect.right + 10f * density, - cardRect.top + 22f * density, + cardRect.top + titleBaseline, textPaint, ) if (!collapsed) { @@ -1253,7 +1259,7 @@ private class DiffCanvasView(context: Context) : View(context) { cardRect.right - bodyX - 18f * density ), bodyX, - cardRect.top + 58f * density, + cardRect.top + drawing.commentBodyBaseline(metrics), textPaint, ) } @@ -1541,9 +1547,6 @@ private fun JSONObject.optNullableInt(key: String): Int? = private fun JSONObject.floatDp(key: String, fallbackPx: Float, density: Float): Float = if (has(key)) optDouble(key).toFloat() * density else fallbackPx -private fun JSONObject.floatSp(key: String, fallbackPx: Float, density: Float): Float = - if (has(key)) optDouble(key).toFloat() * density else fallbackPx - @Suppress("ReturnCount") private fun rowIndexAtOffset(y: Int, rowOffsets: IntArray, rowCount: Int): Int { if (rowCount == 0) return 0 diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 8d8a84c406df..731ac52bad7e 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -1034,11 +1034,22 @@ private struct ReviewDiffGeometryConfiguration: Equatable { let collapsedFileIds: Set let collapsedCommentIds: Set + var commentLineHeight: CGFloat { + ceil(UIFont.systemFont(ofSize: style.fileHeaderSubtextFontSize, weight: style.fileHeaderSubtextFontWeight).lineHeight) + } + + var commentTitleHeight: CGFloat { commentLineHeight + 4 } + var commentBodyTop: CGFloat { max(42, commentTitleHeight + 20) } + func height(for row: ReviewDiffNativeRow) -> CGFloat { if row.kind == "file" { return style.fileHeaderHeight } if collapsedFileIds.contains(row.resolvedFileId) { return 0 } if row.kind == "notice" { return max(style.rowHeight * 2, 44) } - if row.kind == "comment" { return collapsedCommentIds.contains(row.id) ? 44 : 124 } + if row.kind == "comment" { + return collapsedCommentIds.contains(row.id) + ? max(44, commentTitleHeight + 24) + : max(124, 24 + commentBodyTop + 3 * (commentLineHeight + 2)) + } return style.rowHeight } } @@ -2150,15 +2161,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let countsGap = min(style.fileHeaderCountGap, 4) let countsWidth = deleteWidth + countsGap + addWidth let countsX = interactiveRects.checkbox.minX - 10 - countsWidth + let countsHeight = max(18, ceil(fileHeaderMetaFont.lineHeight)) drawSingleLineText( deleteText, - rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18), + rect: CGRect(x: countsX, y: centerY - countsHeight / 2, width: deleteWidth, height: countsHeight), color: theme.deleteText, font: fileHeaderMetaFont ) drawSingleLineText( addText, - rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - 9, width: addWidth, height: 18), + rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - countsHeight / 2, width: addWidth, height: countsHeight), color: theme.addText, font: fileHeaderMetaFont ) @@ -2227,7 +2239,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { cardPath.lineWidth = 1 cardPath.stroke() - let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16) + let titleHeight = geometryConfiguration.commentTitleHeight + let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + max(11, titleHeight / 2), width: 16, height: 16) drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed) let title = "Comment on \(row.commentRangeLabel ?? "line")" @@ -2237,7 +2250,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { x: chevronRect.maxX + 10, y: cardRect.minY + 8, width: max(24, cardRect.width - chevronRect.maxX - 28), - height: fileHeaderSubtextFont.lineHeight + 4 + height: titleHeight ), color: theme.mutedText, font: fileHeaderSubtextFont @@ -2252,9 +2265,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { body.isEmpty ? "Comment" : body, rect: CGRect( x: cardRect.minX + 18, - y: cardRect.minY + 42, + y: cardRect.minY + geometryConfiguration.commentBodyTop, width: max(24, cardRect.width - 36), - height: max(20, cardRect.height - 56) + height: max(20, cardRect.height - geometryConfiguration.commentBodyTop - 14) ), color: theme.text, font: fileHeaderSubtextFont, @@ -2287,7 +2300,12 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return ReviewDiffFileHeaderPathLayout( displayPath: displayPath, - rect: CGRect(x: pathX, y: centerY - 10, width: pathWidth, height: 20) + rect: CGRect( + x: pathX, + y: centerY - max(20, ceil(fileHeaderFont.lineHeight)) / 2, + width: pathWidth, + height: max(20, ceil(fileHeaderFont.lineHeight)) + ) ) } @@ -2704,7 +2722,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { maximumLineCount: Int ) { let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineBreakMode = .byTruncatingTail + paragraphStyle.lineBreakMode = .byWordWrapping paragraphStyle.lineSpacing = 2 let attributes: [NSAttributedString.Key: Any] = [ @@ -2715,8 +2733,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { ] let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing) (text as NSString).draw( - in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)), - withAttributes: attributes + with: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)), + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], + attributes: attributes, + context: nil ) } diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm b/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm index e0b82bb2dbb9..145ac8d0a0c7 100644 --- a/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm @@ -22,7 +22,7 @@ baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; Float fontSizeMultiplier = 1.0; - if (baseTextAttributes.allowFontScaling) { + if (baseProps.allowFontScaling) { fontSizeMultiplier = layoutContext.fontSizeMultiplier; } diff --git a/apps/mobile/modules/t3-source-text/src/SourceText.tsx b/apps/mobile/modules/t3-source-text/src/SourceText.tsx index 70bbb4fd48a6..99285aa8943e 100644 --- a/apps/mobile/modules/t3-source-text/src/SourceText.tsx +++ b/apps/mobile/modules/t3-source-text/src/SourceText.tsx @@ -1,10 +1,24 @@ import React, { type ComponentRef, type Ref } from "react"; -import { Platform, StyleSheet, Text, type TextProps, type TextStyle } from "react-native"; +import { + Platform, + StyleSheet, + Text, + useWindowDimensions, + type TextProps, + type TextStyle, +} from "react-native"; import T3SourceTextRunNativeComponent from "./T3SourceTextRunNativeComponent"; import T3SourceTextNativeComponent from "./T3SourceTextNativeComponent"; import { flattenStyles } from "./util"; -const TextAncestorContext = React.createContext<[boolean, TextStyle]>([false, {}]); +interface SourceTextContext { + readonly style: TextStyle; + readonly allowFontScaling: boolean; + readonly fontScale: number; +} + +const ROOT_STYLE: TextStyle = {}; +const TextAncestorContext = React.createContext(null); export type SourceTextProps = Omit & { nativeTextRef?: Ref>; @@ -13,19 +27,55 @@ export type SourceTextProps = Omit void; }; -export function SourceText({ style, children, nativeTextRef, ...props }: SourceTextProps) { - const [isAncestor, rootStyle] = React.useContext(TextAncestorContext); - const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); - const contextValue = React.useMemo<[boolean, TextStyle]>( - () => [true, StyleSheet.flatten([rootStyle, style]) ?? {}], - [rootStyle, style], +export function SourceText(props: SourceTextProps) { + const ancestor = React.useContext(TextAncestorContext); + return ancestor ? ( + + ) : ( + + ); +} + +function SourceTextRoot(props: SourceTextProps) { + const { fontScale } = useWindowDimensions(); + return ; +} + +function SourceTextContent({ + ancestor, + systemFontScale, + style, + children, + nativeTextRef, + ...props +}: SourceTextProps & { readonly ancestor?: SourceTextContext; readonly systemFontScale: number }) { + const rootStyle = ancestor?.style ?? ROOT_STYLE; + const allowFontScaling = props.allowFontScaling ?? ancestor?.allowFontScaling ?? true; + const scale = Platform.OS === "ios" && allowFontScaling ? systemFontScale : 1; + const flattenedStyle = React.useMemo(() => { + const base = flattenStyles(rootStyle, style); + return { + ...base, + fontSize: (base.fontSize ?? 14) * scale, + lineHeight: base.lineHeight === undefined ? undefined : base.lineHeight * scale, + }; + }, [rootStyle, style, scale]); + const contextValue = React.useMemo( + () => ({ + style: StyleSheet.flatten([rootStyle, style]) ?? ROOT_STYLE, + allowFontScaling, + fontScale: systemFontScale, + }), + [rootStyle, style, allowFontScaling, systemFontScale], ); if (Platform.OS !== "ios") { const { onSelectionChange: _onSelectionChange, ...textProps } = props; return ( - - {children} - + + + {children} + + ); } const runs = React.Children.toArray(children).map( @@ -45,12 +95,17 @@ export function SourceText({ style, children, nativeTextRef, ...props }: SourceT ); } /* oxlint-enable react/no-array-index-key */, ); - if (isAncestor) { + if (ancestor) { return {runs}; } return ( - + {runs} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index f4de25460d5c..6be3173a599a 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -3,6 +3,7 @@ package expo.modules.t3terminal import android.content.Context import android.graphics.Color import android.graphics.Typeface +import android.util.TypedValue import android.text.Editable import android.text.InputType import android.text.TextWatcher @@ -84,8 +85,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex var fontSize: Float = 10f set(value) { field = value - terminalCanvas.fontSizeSp = value - inputView.textSize = max(value, 13f) + terminalCanvas.fontSize = value + inputView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, max(value, 13f)) emitResize() } @@ -149,7 +150,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } init { - terminalCanvas.fontSizeSp = fontSize + terminalCanvas.fontSize = fontSize terminalCanvas.onRequestKeyboard = { requestKeyboardFocus() } terminalCanvas.onScrollRows = { delta -> if (terminalHandle != 0L) { @@ -243,7 +244,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex inputView.setHintTextColor(Color.TRANSPARENT) inputView.setBackgroundColor(Color.TRANSPARENT) inputView.typeface = Typeface.MONOSPACE - inputView.textSize = max(fontSize, 13f) + inputView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, max(fontSize, 13f)) inputView.alpha = 0.01f inputView.isFocusableInTouchMode = true inputView.imeOptions = EditorInfo.IME_ACTION_SEND or diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt index 50cd48b3380e..8c3242f6e8b2 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt @@ -73,7 +73,6 @@ internal class TerminalCanvasView(context: Context) : View(context) { } private val density = resources.displayMetrics.density - private val scaledDensity = density * resources.configuration.fontScale private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) init { @@ -147,7 +146,7 @@ internal class TerminalCanvasView(context: Context) : View(context) { private var selectionEndCol = 0 private var selectionEndRow = 0 - var fontSizeSp: Float = 10f + var fontSize: Float = 10f set(value) { if (field == value) return field = value @@ -283,7 +282,7 @@ internal class TerminalCanvasView(context: Context) : View(context) { } private fun updateCellMetrics() { - paint.textSize = fontSizeSp * scaledDensity + paint.textSize = fontSize * density paint.typeface = regularTypeface cellWidthPx = ceil(paint.measureText("M").toDouble()).toFloat().coerceAtLeast(1f) val metrics = paint.fontMetrics @@ -303,7 +302,7 @@ internal class TerminalCanvasView(context: Context) : View(context) { italic -> italicTypeface else -> regularTypeface } - paint.textSize = fontSizeSp * scaledDensity + paint.textSize = fontSize * density paint.color = color paint.style = Paint.Style.FILL paint.isUnderlineText = flags and FLAG_UNDERLINE != 0 diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 272d3be266dd..a1dc36256c01 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -17,7 +17,6 @@ export function brandTitleOffset(): number { export function CompactBrandTitle( props: { - readonly allowFontScaling?: boolean; readonly accessibilityLabel?: string; /** Fills the row and hides the stage badge when the full title does not fit. */ readonly fit?: boolean; @@ -50,7 +49,6 @@ export function CompactBrandTitle( > @@ -66,7 +64,6 @@ export function CompactBrandTitle( } > @@ -90,7 +87,7 @@ export function CompactBrandTitle( } export function renderCompactBrandTitle() { - return ; + return ; } export function getCompactBrandHeaderOptions( diff --git a/apps/mobile/src/components/ComposerContextAttachment.tsx b/apps/mobile/src/components/ComposerContextAttachment.tsx index edb5522f3d0f..e48686e30b1c 100644 --- a/apps/mobile/src/components/ComposerContextAttachment.tsx +++ b/apps/mobile/src/components/ComposerContextAttachment.tsx @@ -99,7 +99,7 @@ export function ComposerContextAttachment(props: { ) : null} {error || (!local && asset._tag === "Failure") ? ( - + {error ?? "Attachment unavailable. Reconnect and try again."} ) : null} diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d..eebf4e8fa824 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -98,7 +98,7 @@ export function ComposerContextSheet(props: { }); const reviewColors = useReviewCommentColors(); const insets = useSafeAreaInsets(); - const { themeId, themeAppearance } = useAppearancePreferences(); + const { themeId, themeAppearance, appearance } = useAppearancePreferences(); const terminalTheme = getMobileTerminalTheme(themeId, themeAppearance); const record = props.record; const localAttachment = @@ -207,12 +207,12 @@ export function ComposerContextSheet(props: { ) : null} {!record ? ( - + Context unavailable. The reference was copied without its payload. Copy it again from the original message or remove it. ) : "payload" in record ? ( - + This context type is not supported by this version of the app. Its payload will be preserved when sent. @@ -233,8 +233,8 @@ export function ComposerContextSheet(props: { className="text-sm text-foreground" style={{ fontFamily: REVIEW_MONO_FONT_FAMILY, - fontSize: 13, - lineHeight: 20, + fontSize: appearance.terminalFontSize, + lineHeight: Math.round(appearance.terminalFontSize * 1.6), color: terminalTheme.foreground, }} > @@ -369,7 +369,9 @@ export function ComposerContextSheet(props: { }} className="rounded-xl bg-subtle p-4" > - Open {changeRequestTerm} + + Open {changeRequestTerm} + ) : null} {props.onOpenAttachment ? ( diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index b0cf1fc5c3ab..e7abc3cfdea0 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -2,7 +2,6 @@ import { Button } from "./ui/Button"; import * as Dialog from "./ui/Dialog"; import { useEffect, useEffectEvent } from "react"; import { Modal, View } from "react-native"; -import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; @@ -29,7 +28,11 @@ function PdfDocumentPreview(props: { style={{ paddingTop: insets.top, paddingBottom: insets.bottom }} > - + {props.source.name ?? "PDF"} @@ -81,8 +84,10 @@ function DocumentPreview(props: { - Opening document… - + + Opening document… + + {name ?? "Document"} @@ -101,20 +106,5 @@ export function FilePreview(props: { }) { if (props.source.kind === "pdf") return ; if (props.source.kind !== "image") return ; - if (props.source.actionsSource) return ; - return ( - ({ uri: item.uri })) ?? [{ uri: props.source.uri }] - } - imageIndex={Math.max( - 0, - props.source.gallery?.findIndex((item) => item.uri === props.source.uri) ?? 0, - )} - visible - onRequestClose={props.onRequestClose} - swipeToCloseEnabled={false} - doubleTapToZoomEnabled - /> - ); + return ; } diff --git a/apps/mobile/src/components/NewThreadButton.shared.tsx b/apps/mobile/src/components/NewThreadButton.shared.tsx index fac535e09e97..e8c4cd7db0a6 100644 --- a/apps/mobile/src/components/NewThreadButton.shared.tsx +++ b/apps/mobile/src/components/NewThreadButton.shared.tsx @@ -32,7 +32,7 @@ export function NewThreadButton(props: { type="monochrome" /> {props.extended && props.expanded !== false ? ( - New thread + New thread ) : null} ); diff --git a/apps/mobile/src/components/ui/Button.tsx b/apps/mobile/src/components/ui/Button.tsx index 05c6e0085245..8122fb6a0df4 100644 --- a/apps/mobile/src/components/ui/Button.tsx +++ b/apps/mobile/src/components/ui/Button.tsx @@ -53,6 +53,7 @@ export function Button(props: ButtonProps) { /> ) : null} { diff --git a/apps/mobile/src/components/ui/MenuPopup.android.tsx b/apps/mobile/src/components/ui/MenuPopup.android.tsx index b5af18cd2bcb..41a7c766f8ed 100644 --- a/apps/mobile/src/components/ui/MenuPopup.android.tsx +++ b/apps/mobile/src/components/ui/MenuPopup.android.tsx @@ -10,7 +10,7 @@ import { import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; import type { MenuAction } from "@react-native-menu/menu"; import { Pressable } from "react-native"; -import { resolveScaledTextRole } from "../../lib/appearancePreferences"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; @@ -56,10 +56,10 @@ function MenuIcon(props: { } export function MenuPopup(props: MenuPopupProps) { - const { appearance, themeVariables: colors } = useAppearancePreferences(); + const { themeVariables: colors } = useAppearancePreferences(); const { scale, menuItemHeight } = useAndroidControlSizing(); - const body = resolveScaledTextRole("body", appearance.baseFontSize); - const caption = resolveScaledTextRole("caption", appearance.baseFontSize); + const body = MOBILE_TYPOGRAPHY.body; + const caption = MOBILE_TYPOGRAPHY.caption; const foreground = colors["--color-foreground"]; const muted = colors["--color-foreground-muted"]; // A fixed native item height clips wrapped labels; a minimum lets each row grow. diff --git a/apps/mobile/src/components/ui/NumberField.tsx b/apps/mobile/src/components/ui/NumberField.tsx index e6343d2e7d3c..9643328c0d7e 100644 --- a/apps/mobile/src/components/ui/NumberField.tsx +++ b/apps/mobile/src/components/ui/NumberField.tsx @@ -1,3 +1,4 @@ +import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { Button, HStack, Picker, Popover, Spacer, Text, VStack } from "@expo/ui/swift-ui"; import { accessibilityLabel, @@ -45,7 +46,7 @@ export function NumberField(props: NumberFieldProps) { ), frame({ minWidth: 64, minHeight: 44 }), foregroundStyle(colors["--color-primary-text"]), - font({ size: fontSize }), + font({ family: FONT_FAMILIES.regular, size: fontSize }), ]} > {String(props.value)} @@ -68,7 +69,11 @@ export function NumberField(props: NumberFieldProps) { {values.map((value) => ( {props.formatValue?.(value) ?? String(value)} diff --git a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx index 4862e99cdabf..bb7737fcba11 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx @@ -1,3 +1,4 @@ +import { useSystemFontScale } from "../../lib/useSystemFontScale"; import { requireNativeView } from "expo"; import { View, type NativeSyntheticEvent, type ViewProps } from "react-native"; @@ -6,6 +7,7 @@ import { themeColorWithAlpha, themeColorWithContrast, } from "../../lib/mobileTheme"; +import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useControlTheme } from "./useControlTheme"; import type { SegmentedControlProps } from "./SegmentedControl.types"; @@ -14,6 +16,7 @@ export type { SegmentedControlProps } from "./SegmentedControl.types"; const NativeSegmentedControl = requireNativeView< ViewProps & { labels: string[]; + fontSize: number; selectedIndex: number; trackColor: string; selectedColor: string; @@ -28,6 +31,8 @@ export function SegmentedControl( props: SegmentedControlProps, ) { const { colors, colorScheme } = useControlTheme(); + const typography = useScaledTextRole("label"); + const scaleSystemFont = useSystemFontScale(); const trackColor = flattenThemeColor( themeColorWithAlpha(colors["--color-prominent-action"], colorScheme === "dark" ? 0.18 : 0.07), colors["--color-screen"], @@ -42,6 +47,7 @@ export function SegmentedControl( option.label)} + fontSize={scaleSystemFont(typography.fontSize)} selectedIndex={props.options.findIndex((option) => option.value === props.selected)} trackColor={trackColor} selectedColor={selectedColor} @@ -56,7 +62,12 @@ export function SegmentedControl( const option = props.options[nativeEvent.index]; if (option) props.onSelect(option.value); }} - style={{ height: props.size === "compact" ? 32 : 36 }} + style={{ + height: Math.max( + props.size === "compact" ? 32 : 36, + scaleSystemFont(typography.lineHeight) + 12, + ), + }} /> ); diff --git a/apps/mobile/src/components/ui/SegmentedControl.tsx b/apps/mobile/src/components/ui/SegmentedControl.tsx index 0fdc46c0f27e..a763503aa7de 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.tsx @@ -3,6 +3,7 @@ import { I18nManager, Platform, Pressable, View } from "react-native"; import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; import { AppText } from "./AppText"; +import { cn } from "../../lib/cn"; import { useControlTheme } from "./useControlTheme"; import type { SegmentedControlProps } from "./SegmentedControl.types"; @@ -56,12 +57,15 @@ export function SegmentedControl( accessibilityLabel={option.accessibilityLabel ?? option.label} accessibilityState={{ selected }} onPress={() => props.onSelect(option.value)} - className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" - style={{ minHeight: minHeight - 6 }} + className="min-w-0 items-center justify-center rounded-full px-2 py-1 active:opacity-70" + style={{ minHeight: minHeight - 6, width: segmentWidth }} > + {label} @@ -421,7 +421,7 @@ function PairingInputRow({ accessibilityLabel={label} autoCapitalize="none" autoCorrect={false} - className="min-h-0 flex-1 rounded-none border-0 bg-transparent p-0 text-base leading-[23px]" + className="min-h-0 flex-1 rounded-none border-0 bg-transparent p-0 text-base" /> ); diff --git a/apps/mobile/src/features/development/ComponentCatalogScreen.tsx b/apps/mobile/src/features/development/ComponentCatalogScreen.tsx index 9a3d894d384f..705735d7b97c 100644 --- a/apps/mobile/src/features/development/ComponentCatalogScreen.tsx +++ b/apps/mobile/src/features/development/ComponentCatalogScreen.tsx @@ -179,7 +179,7 @@ export function ComponentCatalogScreen() { /> - Count + Count Native app bar} + title={Native app bar} navigationIcon={ {}} /> } diff --git a/apps/mobile/src/features/files/AudioFilePreview.tsx b/apps/mobile/src/features/files/AudioFilePreview.tsx index f9c92a5e201c..8072cc0c5a05 100644 --- a/apps/mobile/src/features/files/AudioFilePreview.tsx +++ b/apps/mobile/src/features/files/AudioFilePreview.tsx @@ -43,7 +43,7 @@ export function AudioFilePreview(props: { uri: string; onRetry: () => void }) { }; return ( - + {timestamp(status.currentTime)} / {timestamp(status.duration)} @@ -84,7 +84,7 @@ export function AudioFilePreview(props: { uri: string; onRetry: () => void }) { {status.error || seekError ? ( - + This audio could not be played. Try again or save it to open in another app. diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index bbd805ca5746..6bc80058c65c 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -124,7 +124,11 @@ export function FileMarkdownPreview(props: { accessibilityRole="link" accessibilityLabel={`Open ${name}`} onPress={() => openMedia(image.href, true)} - style={{ color: styles.nativeTextStyle.linkColor }} + style={{ + color: styles.nativeTextStyle.linkColor, + fontSize: styles.nativeTextStyle.fontSize, + lineHeight: styles.nativeTextStyle.lineHeight, + }} > {name} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index bf691fa4bef7..d4b387b2b571 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -65,6 +65,7 @@ const HighlightedSourceLine = function HighlightedSourceLine(props: { style={{ minHeight: props.codeSurface.rowHeight }} > 0 ? lineTokens.map((token, tokenIndex) => ( + {search.isPending ? "Searching…" : "No results"} } diff --git a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx index ecd811f49540..85265c18e02d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx @@ -196,7 +196,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss /> ) : !activity.isPending ? ( - This discussion is no longer available. + This discussion is no longer available. ) : null} {comments.map((comment) => ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx index f8f7a5f9c5b1..04ada09b0c80 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListRow.tsx @@ -136,7 +136,7 @@ export const PullRequestListRow = memo( diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx index 1fa5cd83b874..8658d7c4c5f1 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx @@ -273,7 +273,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps ) : ( - + {detail.error ?? `Couldn't load this ${singular}.`} ) @@ -306,7 +306,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps void update({ id: query.trim(), kind: "user", isRequested: true })} /> {error ? ( - + {error} ) : null} diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index b3094f648b5c..dd2924831471 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -202,7 +202,7 @@ function PullRequestIdentity(props: { )} - + {pr.title} diff --git a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx index 6b4eef34abd6..af5e140a2bad 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx @@ -37,7 +37,7 @@ export function GuideNotice({ state }: { readonly state: ReturnType - {notice.description} + {notice.description} {state.canWrite || busy || notice.kind === "unavailable" ? ( diff --git a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx index 1308d4caee79..45b197250231 100644 --- a/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx +++ b/apps/mobile/src/features/pull-requests/guide/GuideOutline.tsx @@ -83,7 +83,7 @@ export function GuideOutline({ ) : null} - {stop.title} + {stop.title} {stop.attention ? ( diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index fe5247b51d4a..679a3cbc3436 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -55,7 +55,7 @@ export function PullRequestGuideContent({ params }: { readonly params: PullReque return ( {!state.enabled ? ( - Review guides are disabled for this server. + Review guides are disabled for this server. ) : ( <> @@ -172,15 +172,17 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps {!state.enabled ? ( - Review guides are disabled for this server. + Review guides are disabled for this server. ) : changedGuide ? ( - This guide was rewritten. Go back to start the new tour. + + This guide was rewritten. Go back to start the new tour. + ) : !stop ? ( - {state.error ?? "Loading stop…"} + {state.error ?? "Loading stop…"} ) : ( <> - {stop.title} + {stop.title} {stop.attention ? ( @@ -216,17 +218,25 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps )) )} - {diff.error ? {diff.error} : null} + {diff.error ? ( + + {diff.error} + + ) : null} {notes.length ? ( - Notes on this stop + + Notes on this stop + {notes.map(({ marker, note }) => ( {marker} - {note.title} + + {note.title} + {note.path.split("/").at(-1)}:{note.line} @@ -257,7 +267,9 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps - Questions to consider + + Questions to consider + {stop.questions.map((question) => ( ))} diff --git a/apps/mobile/src/features/review/ReviewCommentCard.tsx b/apps/mobile/src/features/review/ReviewCommentCard.tsx index ef1eff43b454..995ec92d02a4 100644 --- a/apps/mobile/src/features/review/ReviewCommentCard.tsx +++ b/apps/mobile/src/features/review/ReviewCommentCard.tsx @@ -209,6 +209,7 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { contentContainerStyle={{ padding: 10 }} > ( /* oxlint-disable react/no-array-index-key -- Highlight tokens are stateless positions within this immutable snippet. */ {lineIndex > 0 ? "\n" : ""} {line.map((token, tokenIndex) => ( - + {lineNumber ?? ""} - + - {props.title} + + {props.title} + ); @@ -208,13 +210,13 @@ function ReviewSelectionActionBar(props: { > {props.onOpenComment ? ( {content} ) : ( - + {content} )} diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index d55aa97e305c..9b821b70ffab 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -2,7 +2,10 @@ import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/native import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; -import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; +import { + DEFAULT_CODE_FONT_SIZE, + type ResolvedMobileCodeSurface, +} from "../../lib/appearancePreferences"; import { resolveHighlightLanguage } from "../../lib/highlightLanguage"; import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme"; import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; @@ -69,6 +72,8 @@ export function createNativeReviewDiffStyle( codeSurface: ResolvedMobileCodeSurface, wordWrap: boolean, ) { + const scale = codeSurface.fontSize / DEFAULT_CODE_FONT_SIZE; + const scaled = (size: number) => Math.max(8, Math.round(size * scale)); return { wordWrap, rowHeight: codeSurface.rowHeight, @@ -77,29 +82,29 @@ export function createNativeReviewDiffStyle( gutterWidth: codeSurface.gutterWidth, codePadding: codeSurface.codePadding, textVerticalInset: codeSurface.textVerticalInset, - fileHeaderHeight: 56, + fileHeaderHeight: scaled(56), fileHeaderHorizontalMargin: 8, fileHeaderVerticalMargin: 6, fileHeaderCornerRadius: 10, - fileHeaderHorizontalPadding: 10, - fileHeaderPathRightPadding: 118, - fileHeaderCountColumnWidth: 38, + fileHeaderHorizontalPadding: scaled(10), + fileHeaderPathRightPadding: scaled(118), + fileHeaderCountColumnWidth: scaled(38), fileHeaderCountGap: 5, codeFontSize: codeSurface.fontSize, codeFontWeight: "regular", lineNumberFontSize: codeSurface.lineNumberFontSize, lineNumberFontWeight: "regular", - hunkFontSize: 11, + hunkFontSize: scaled(11), hunkFontWeight: "medium", - fileHeaderFontSize: 11, + fileHeaderFontSize: scaled(11), fileHeaderFontWeight: "semibold", - fileHeaderMetaFontSize: 10, + fileHeaderMetaFontSize: scaled(10), fileHeaderMetaFontWeight: "semibold", - fileHeaderSubtextFontSize: 11, + fileHeaderSubtextFontSize: scaled(11), fileHeaderSubtextFontWeight: "medium", - fileHeaderStatusFontSize: 9, + fileHeaderStatusFontSize: scaled(9), fileHeaderStatusFontWeight: "bold", - emptyStateFontSize: 12, + emptyStateFontSize: scaled(12), emptyStateFontWeight: "medium", } as const; } diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index 4b2c66a2af97..fd68adfca15d 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -1,3 +1,4 @@ +import { useSystemFontScale } from "../../lib/useSystemFontScale"; import { Platform, Text as NativeText, View } from "react-native"; import { cn } from "../../lib/cn"; @@ -72,11 +73,13 @@ export function DiffTokenText(props: { readonly fontSize?: number; readonly lineHeight?: number; }) { - const fontSize = props.fontSize ?? MOBILE_CODE_SURFACE.fontSize; - const lineHeight = props.lineHeight ?? MOBILE_CODE_SURFACE.rowHeight; + const scaleSystemFont = useSystemFontScale(); + const fontSize = props.fontSize ?? scaleSystemFont(MOBILE_CODE_SURFACE.fontSize); + const lineHeight = props.lineHeight ?? scaleSystemFont(MOBILE_CODE_SURFACE.rowHeight); if (!props.tokens || props.tokens.length === 0) { return ( - {WORKSPACE_TAB_LABELS[tab]} + {WORKSPACE_TAB_LABELS[tab]} {hidden.includes(tab) ? "Hidden" diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index e9774d37d4d1..f4ebe401914d 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -217,7 +217,9 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro onPress={() => void Linking.openURL(sourceUrl)} className="min-h-12 flex-row items-center gap-2 self-start py-2 active:opacity-60" > - Project source + + Project source + {selectedTargets.length === 0 ? ( - Select a connected environment. + + Select a connected environment. + ) : ( selectedTargets.map((environment) => ( @@ -77,7 +79,7 @@ export function SettingsProviderAccountsRouteScreen() { provider.setup?.canAuthenticate || (provider.driver === "acpRegistry" && provider.installed), ) ? ( - + Configure a provider with in-app sign-in in web or desktop Settings. ) : null} @@ -209,7 +211,7 @@ function ProviderAccount({ ) : null} {interaction?.type === "deviceCode" ? ( - + Enter code {interaction.userCode} on the sign-in page. ) : null} @@ -272,7 +274,7 @@ function ProviderAccount({ ) : null} {error || auth.error ? ( - + {error ?? auth.error} ) : null} diff --git a/apps/mobile/src/features/settings/appearance/systemFontMetrics.ts b/apps/mobile/src/features/settings/appearance/systemFontMetrics.ts new file mode 100644 index 000000000000..d0601f9d400e --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/systemFontMetrics.ts @@ -0,0 +1,51 @@ +import type { ResolvedMobileCodeSurface } from "../../../lib/appearancePreferences"; +import type { NativeReviewDiffStyle } from "../../diffs/nativeReviewDiffSurface"; + +export function scaleCodeSurfaceMetrics( + base: ResolvedMobileCodeSurface, + scaleFont: (size: number) => number, +): ResolvedMobileCodeSurface { + const fontSize = scaleFont(base.fontSize); + const lineNumberFontSize = scaleFont(base.lineNumberFontSize); + return { + ...base, + fontSize, + lineNumberFontSize, + rowHeight: Math.ceil(base.rowHeight * (fontSize / base.fontSize)), + gutterWidth: Math.ceil(base.gutterWidth * (lineNumberFontSize / base.lineNumberFontSize)), + }; +} + +const FONT_KEYS = [ + "codeFontSize", + "lineNumberFontSize", + "hunkFontSize", + "fileHeaderFontSize", + "fileHeaderMetaFontSize", + "fileHeaderSubtextFontSize", + "fileHeaderStatusFontSize", + "emptyStateFontSize", +] as const; + +export function scaleNativeCodeStyle

Tool-call audit

"}, "result": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}}, {"tool": "html_render", "input": {"title": "Tool-call audit", "height": 560, "html": "

Tool-call audit

"}, "result": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}]}], "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Why do you always add awful black backgrounds to your inline html? Fix this one.", "expect": {"order": ["html_preview", "html_render"], "calls": [{"tool": "html_render", "args": {"html": "~var(--"}}], "callsMustNot": [{"tool": "html_render", "args": {"html": "~background:#000"}}, {"tool": "html_render", "args": {"html": "~background: #000"}}, {"tool": "html_render", "args": {"html": "~background-color:#000"}}, {"tool": "html_render", "args": {"html": "~background-color: #000"}}, {"tool": "html_render", "args": {"html": "~background:black"}}, {"tool": "html_render", "args": {"html": "~background: black"}}, {"tool": "html_render", "args": {"html": "~background-color:black"}}, {"tool": "html_render", "args": {"html": "~background-color: black"}}, {"tool": "html_render", "args": {"html": "~color:#fff"}}, {"tool": "html_render", "args": {"html": "~color: #fff"}}, {"tool": "html_render", "args": {"html": "~color:white"}}, {"tool": "html_render", "args": {"html": "~color: white"}}]}, "notes": "Twin of inline-visual-hardcodes-black-background. The prior render is the pressure to keep its CSS."} -{"id": "control-fix-failing-tests", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: fix failing tests", "experience": "Fix failing tests from a release run. No T3 tool is needed.", "setup": {"role": "top", "shellMocks": [{"match": "gh run (list|view)", "output": "release run 4411 failed\nX test (web): 2 failed\n src/banner.test.ts > hides notice on click: element is not visible\n src/banner.test.ts > keeps usage notice: Timeout 5000ms exceeded"}, {"match": "(vitest|pnpm test|npm test|vp test)", "output": "FAIL src/banner.test.ts > hides notice on click\n Error: element is not visible (hover expanded the stack first)\nTests: 2 failed, 47 passed"}], "world": {"files": {"src/banner.test.ts": "test('hides notice on click', async () => {\n await user.hover(stack)\n await user.click(screen.getByRole('button', { name: 'Dismiss' }))\n})\n", "src/banner.tsx": "export function Banner() {\n return
...
;\n}\n"}}}, "prompt": "Bunch of test failures on main in the last release. Figure out the cause and fix.", "notes": "Same thread later has PR overhead cases; the first turn is clean."} -{"id": "control-rename-auth-file", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: rename a file", "experience": "Rename a local config file. No T3 tool is needed.", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Where does the acme CLI keep its login?"}, {"role": "assistant", "text": "In ~/.config/acme/auth.json."}], "world": {"files": {"~/.config/acme/auth.json": "{\"token\":\"\"}\n", "~/.config/acme/config.json": "{\"region\":\"us\"}\n"}}}, "prompt": "Rename the auth file to auth_legacy.json for me"} -{"id": "control-explain-why-change", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: explain a change from git history", "experience": "Explain why a commit was made. No T3 tool is needed.", "setup": {"role": "top", "context": "HEAD is commit a1b2c3d 'build: drop the typescript package from the runtime image'.", "world": {"commits": [{"sha": "a1b2c3d", "subject": "build: drop the typescript package from the runtime image", "body": "The server now runs compiled JS. tsc was only used at build time and added 23 MB to the image."}], "diff": "diff --git a/Dockerfile b/Dockerfile\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -10,1 +10,1 @@\n-RUN pnpm install --prod --filter server... typescript\n+RUN pnpm install --prod --filter server...\n"}}, "prompt": "Why was this change made?"} -{"id": "control-run-lint-and-fix", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: run lint and fix", "experience": "Run lint and fix the findings. No T3 tool is needed.", "setup": {"role": "top", "shellMocks": [{"match": "lint", "output": "src/util.ts\n 12:7 error 'unused' is assigned a value but never used no-unused-vars\n 30:1 error Missing return type on function explicit-function-return-type\n2 problems"}], "world": {"files": {"src/util.ts": "export function sum(xs: number[]) {\n const unused = 0;\n return xs.reduce((a, b) => a + b, 0);\n}\n\nexport function label(n: number) {\n return `${n} items`;\n}\n"}}}, "prompt": "Run lint and fix whatever it finds.", "notes": "Prompt is generic; the source only shows lint as ordinary work."} -{"id": "control-refactor-chart-component", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task with a visual-sounding word ('chart')", "experience": "Refactor a chart component. The word 'chart' must not pull the agent into html_render.", "setup": {"role": "top", "world": {"files": {"src/components/UsageChart.tsx": "import { useState } from 'react';\nimport { nearest, type Row } from '../lib/chart';\nimport { Tooltip } from './Tooltip';\n\ntype Props = { rows: Row[] };\n\nexport function UsageChart({ rows }: Props) {\n const [hover, setHover] = useState(null)\n const onMove = (e: MouseEvent) => { /* find nearest row */ setHover(nearest(rows, e.clientX)) }\n return {/* bars */}{hover && }\n}\n", "src/lib/chart.ts": "export type Row = { day: string; tokens: number };\nexport const nearest = (rows: Row[], x: number) => rows[Math.round(x)] ?? null;\n"}}}, "prompt": "Refactor src/components/UsageChart.tsx so the tooltip hover logic lives in its own hook. No behavior change.", "notes": "Prompt adapted. Catches a rewrite that makes any chart mention trigger html_render."} -{"id": "control-explain-preview-manager-code", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task", "t3_preview_close"]}, "pattern": "ordinary task with a T3-sounding word ('preview tab')", "experience": "Explain code about preview tabs. The words 'preview tab' must not pull the agent into preview tools.", "setup": {"role": "top", "world": {"files": {"apps/server/src/preview/Manager.ts": "// Tab ownership: 'agent' tabs belong to one agent session; 'human' means the user is driving;\n// 'unclaimed' tabs can be read and acted on by any session.\nexport function canAct(tab: Tab, caller: SessionId) {\n if (tab.owner === 'human') return false\n if (tab.owner === 'unclaimed') return true\n return tab.ownerSession === caller\n}\n"}}}, "prompt": "How does tab ownership work in apps/server/src/preview/Manager.ts? Explain it briefly.", "notes": "Prompt adapted; the source is an ordinary explain-code turn."} +{"id": "control-fix-failing-tests", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: fix failing tests", "experience": "Fix failing tests from a release run. No T3 tool is needed.", "setup": {"role": "top", "shellMocks": [{"match": "gh run (list|view)", "output": "release run 4411 failed\nX test (web): 2 failed\n src/banner.test.ts > hides notice on click: element is not visible\n src/banner.test.ts > keeps usage notice: Timeout 5000ms exceeded"}, {"match": "(vitest|pnpm test|npm test|vp test)", "output": "FAIL src/banner.test.ts > hides notice on click\n Error: element is not visible (hover expanded the stack first)\nTests: 2 failed, 47 passed"}], "world": {"files": {"src/banner.test.ts": "test('hides notice on click', async () => {\n await user.hover(stack)\n await user.click(screen.getByRole('button', { name: 'Dismiss' }))\n})\n", "src/banner.tsx": "export function Banner() {\n return
...
;\n}\n"}}}, "prompt": "Bunch of test failures on main in the last release. Figure out the cause and fix.", "notes": "Same thread later has PR overhead cases; the first turn is clean."} +{"id": "control-rename-auth-file", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: rename a file", "experience": "Rename a local config file. No T3 tool is needed.", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Where does the acme CLI keep its login?"}, {"role": "assistant", "text": "In ~/.config/acme/auth.json."}], "world": {"files": {"~/.config/acme/auth.json": "{\"token\":\"\"}\n", "~/.config/acme/config.json": "{\"region\":\"us\"}\n"}}}, "prompt": "Rename the auth file to auth_legacy.json for me"} +{"id": "control-explain-why-change", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: explain a change from git history", "experience": "Explain why a commit was made. No T3 tool is needed.", "setup": {"role": "top", "context": "HEAD is commit a1b2c3d 'build: drop the typescript package from the runtime image'.", "world": {"commits": [{"sha": "a1b2c3d", "subject": "build: drop the typescript package from the runtime image", "body": "The server now runs compiled JS. tsc was only used at build time and added 23 MB to the image."}], "diff": "diff --git a/Dockerfile b/Dockerfile\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -10,1 +10,1 @@\n-RUN pnpm install --prod --filter server... typescript\n+RUN pnpm install --prod --filter server...\n"}}, "prompt": "Why was this change made?"} +{"id": "control-run-lint-and-fix", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: run lint and fix", "experience": "Run lint and fix the findings. No T3 tool is needed.", "setup": {"role": "top", "shellMocks": [{"match": "lint", "output": "src/util.ts\n 12:7 error 'unused' is assigned a value but never used no-unused-vars\n 30:1 error Missing return type on function explicit-function-return-type\n2 problems"}], "world": {"files": {"src/util.ts": "export function sum(xs: number[]) {\n const unused = 0;\n return xs.reduce((a, b) => a + b, 0);\n}\n\nexport function label(n: number) {\n return `${n} items`;\n}\n"}}}, "prompt": "Run lint and fix whatever it finds.", "notes": "Prompt is generic; the source only shows lint as ordinary work."} +{"id": "control-refactor-chart-component", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task with a visual-sounding word ('chart')", "experience": "Refactor a chart component. The word 'chart' must not pull the agent into html_render.", "setup": {"role": "top", "world": {"files": {"src/components/UsageChart.tsx": "import { useState } from 'react';\nimport { nearest, type Row } from '../lib/chart';\nimport { Tooltip } from './Tooltip';\n\ntype Props = { rows: Row[] };\n\nexport function UsageChart({ rows }: Props) {\n const [hover, setHover] = useState(null)\n const onMove = (e: MouseEvent) => { /* find nearest row */ setHover(nearest(rows, e.clientX)) }\n return {/* bars */}{hover && }\n}\n", "src/lib/chart.ts": "export type Row = { day: string; tokens: number };\nexport const nearest = (rows: Row[], x: number) => rows[Math.round(x)] ?? null;\n"}}}, "prompt": "Refactor src/components/UsageChart.tsx so the tooltip hover logic lives in its own hook. No behavior change.", "notes": "Prompt adapted. Catches a rewrite that makes any chart mention trigger html_render."} +{"id": "control-explain-preview-manager-code", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task", "t3_preview_close"]}, "pattern": "ordinary task with a T3-sounding word ('preview tab')", "experience": "Explain code about preview tabs. The words 'preview tab' must not pull the agent into preview tools.", "setup": {"role": "top", "world": {"files": {"apps/server/src/preview/Manager.ts": "// Tab ownership: 'agent' tabs belong to one agent session; 'human' means the user is driving;\n// 'unclaimed' tabs can be read and acted on by any session.\nexport function canAct(tab: Tab, caller: SessionId) {\n if (tab.owner === 'human') return false\n if (tab.owner === 'unclaimed') return true\n return tab.ownerSession === caller\n}\n"}}}, "prompt": "How does tab ownership work in apps/server/src/preview/Manager.ts? Explain it briefly.", "notes": "Prompt adapted; the source is an ordinary explain-code turn."} {"id": "proactive-visual-latency-regression", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user pasted a latency table across releases and asked where it regressed. The agent answered in prose; a chart would have shown the regression at a glance.", "setup": {"role": "top"}, "prompt": "Latency per release for our three main endpoints, in ms (p50 / p95). Which release regressed, and on which endpoint?\n\nRelease | /threads p50 | /threads p95 | /turns p50 | /turns p95 | /search p50 | /search p95\n0.41 | 38 | 92 | 120 | 310 | 44 | 101\n0.42 | 37 | 95 | 118 | 305 | 46 | 104\n0.43 | 39 | 97 | 121 | 318 | 45 | 99\n0.44 | 41 | 210 | 119 | 312 | 47 | 102\n0.45 | 40 | 236 | 162 | 480 | 46 | 100\n0.46 | 42 | 241 | 165 | 492 | 48 | 103", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} {"id": "proactive-visual-ci-time-breakdown", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user shared CI job times over several runs and asked what eats the time. The agent answered in prose; a stacked chart would have shown it.", "setup": {"role": "top"}, "prompt": "CI got slow. Here are job times in minutes for the last 8 runs on main. What's eating the time?\n\nRun | install | typecheck | lint | test-server | test-web | build | e2e\n1 | 2.1 | 3.0 | 1.2 | 6.8 | 4.1 | 3.3 | 7.9\n2 | 2.0 | 3.1 | 1.1 | 6.9 | 4.0 | 3.4 | 8.2\n3 | 2.2 | 3.0 | 1.2 | 7.4 | 4.2 | 3.3 | 8.0\n4 | 2.1 | 3.2 | 1.3 | 9.8 | 4.1 | 3.5 | 8.1\n5 | 2.3 | 3.1 | 1.2 | 12.6 | 4.3 | 3.4 | 8.4\n6 | 2.2 | 3.3 | 1.2 | 13.1 | 4.2 | 3.6 | 12.9\n7 | 2.1 | 3.2 | 1.3 | 13.4 | 4.4 | 3.5 | 13.2\n8 | 2.2 | 3.2 | 1.2 | 13.0 | 4.3 | 3.4 | 13.5", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} {"id": "requested-after-video-for-edit", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "agent refuses requested browser recording of its own edit", "experience": "A user asked for an after video of a UI fix. The agent declined because the injected instructions prohibited preview for its own edits.", "rule": "Use preview when the user asks for browser work, including recording an edit.", "setup": {"role": "top", "context": "The UI fix is complete and a development server is already running at http://localhost:3000. Browser verification and recording are authorized.", "mocks": {"preview_status": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_snapshot": {"available": true, "tabId": "tab_1", "url": "http://localhost:3000/"}, "preview_recording_start": {"recording": true, "tabId": "tab_1", "startedAt": "2026-01-01T00:00:00.000Z"}, "preview_recording_stop": {"id": "recording_1", "tabId": "tab_1", "path": "/tmp/after.webm", "mimeType": "video/webm", "sizeBytes": 1024, "createdAt": "2026-01-01T00:00:01.000Z"}}}, "prompt": "Use the T3 browser to verify the UI fix you just made and record an after video.", "expect": {"calls": [{"tool": "preview_recording_start"}, {"tool": "preview_recording_stop"}], "order": ["preview_recording_start", "preview_recording_stop"]}, "source": {"report": "A user requested an after video of a UI fix; the agent declined because preview was prohibited for its own edits."}} diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index b58dee92081b..1bb42346d650 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -751,7 +751,13 @@ export const makeSample = (testCase: McpToolUseCase, base: Base): Sample => { ...priorCalls.flatMap((call) => ["link_pull_request", "watch_pull_request"].includes(call.tool) && isRecord(call.input) ? [pullRequest(repo, call.input).url] - : [], + : call.tool === "link_pull_requests" && + isRecord(call.input) && + Array.isArray(call.input.urls) + ? call.input.urls + .filter((url) => PULL_REQUEST_URL.test(valueText(url))) + .map((url) => pullRequest(repo, { url }).url) + : [], ), ]; const taskNumbers = [...valueText(prior).matchAll(/task-(\d+)/g)].map((match) => @@ -956,6 +962,8 @@ const DEFAULT_THREAD = { const HTML_RENDERED = "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."; +const PULL_REQUEST_URL = /\/pull\/\d+\/?$/; + /** Pull request identity from a `url` or `repository` plus `number` input, like T3's results. */ const pullRequest = (repo: string, input: Readonly>) => { const url = typeof input.url === "string" ? input.url : ""; @@ -1022,6 +1030,27 @@ const t3Default = (sample: Sample, name: string, input: Readonly !PULL_REQUEST_URL.test(url)); + const targets = [...new Set(urls.filter((url) => PULL_REQUEST_URL.test(url)))].map((url) => + pullRequest(sample.repo, { url }), + ); + const fresh = targets.filter(({ url }) => !sample.linked.has(url)); + for (const { url } of targets) sample.linked.add(url); + const byRepository = (prs: ReadonlyArray>) => + [...Map.groupBy(prs, (pr) => pr.repository).values()].map((group) => ({ + host: "github.com", + repository: group[0]!.repository, + numbers: group.map((pr) => pr.number), + })); + return { + linked: byRepository(fresh), + alreadyLinked: byRepository(targets.filter((pr) => !fresh.includes(pr))), + linkedCount: targets.length, + ...(invalidUrls.length > 0 ? { invalidUrls } : {}), + }; + } case "request_secret": { // T3 names both a ref and a file with 32 hex characters, and keeps files in its secrets dir. const id = "5f0c9e2a7b4d4183a6e1c8d2b9f07a64"; @@ -1131,6 +1160,7 @@ const withProductFields = (name: string, value: unknown, child: boolean): unknow case "t3_thread_search": return listed("matches"); case "link_pull_request": + case "link_pull_requests": return child ? { ...value, next: CHILD_PULL_REQUEST_REPORT_NEXT } : value; default: return value; diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index a96d2256d005..268306f37d0d 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -35,7 +35,15 @@ describe("summarizeT3ToolCalls", () => { ).toBe("Sent 2 messages to 1 thread"); }); - it("counts the pull requests a batch unlink removed", () => { + it("counts the pull requests batch link and unlink calls changed", () => { + const urls = ["https://github.com/o/r/pull/1", "https://github.com/o/r/pull/1", "not-a-pr"]; + const links = completed({ urls }, { linkedCount: 1 }); + expect(summarizeT3ToolCalls("link-pr", [links, completed({ number: 3 })]).label).toBe( + "Linked 2 pull requests", + ); + expect(summarizeT3ToolCalls("link-pr", [completed({ urls }, {})]).label).toBe( + "Linked pull requests", + ); const batch = completed({ states: ["merged"] }, { unlinkedCount: 2 }); expect(summarizeT3ToolCalls("unlink-pr", [batch, completed({ number: 3 })]).label).toBe( "Unlinked 3 pull requests", diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 854a8583e95e..1d57c1f0c91b 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -79,6 +79,25 @@ function quantity(count: number, noun: string, plural = `${noun}s`): string { return `${count} ${count === 1 ? noun : plural}`; } +/** One PR per single call; a batch counts what its result reports. */ +function pullRequestQuantity( + calls: ReadonlyArray<{ + readonly input: Record | undefined; + readonly output: Record | undefined; + }>, + countKey: "linkedCount" | "unlinkedCount", +): string { + let count = 0; + for (const call of calls) { + const reported = call.output?.[countKey]; + if (typeof reported === "number") count += reported; + else if (call.input?.url !== undefined || call.input?.number !== undefined) count += 1; + // A batch result too large to compact loses its count, so the label drops the number. + else return "pull requests"; + } + return quantity(count, "pull request"); +} + export function summarizeT3ToolCalls( action: T3McpToolSummaryAction, calls: ReadonlyArray, @@ -389,21 +408,11 @@ export function summarizeT3ToolCalls( break; } case "link-pr": - label = phrase("Linked", "link", quantity(selected.length, "pull request")); - break; - case "unlink-pr": { - let count = 0; - let countKnown = true; - for (const call of selected) { - if (typeof call.output?.unlinkedCount === "number") count += call.output.unlinkedCount; - // A batch result too large to compact loses its count, so the label drops the number. - else if (Array.isArray(call.input?.states)) countKnown = false; - else count += 1; - } - const object = countKnown ? quantity(count, "pull request") : "pull requests"; - label = phrase("Unlinked", "unlink", object); + label = phrase("Linked", "link", pullRequestQuantity(selected, "linkedCount")); + break; + case "unlink-pr": + label = phrase("Unlinked", "unlink", pullRequestQuantity(selected, "unlinkedCount")); break; - } case "watch-pr": label = phrase("Watching", "watch", quantity(selected.length, "pull request")); break; diff --git a/packages/provider-core/src/server/runtimeInstructions.test.ts b/packages/provider-core/src/server/runtimeInstructions.test.ts index 0a7778ad7d70..7e1edeb2ac00 100644 --- a/packages/provider-core/src/server/runtimeInstructions.test.ts +++ b/packages/provider-core/src/server/runtimeInstructions.test.ts @@ -7,7 +7,7 @@ describe("buildRuntimeInstructions", () => { const instructions = buildRuntimeInstructions({ harness: "Codex" }); assert.include(instructions, "Link each PR you create, take over, or push to"); - assert.include(instructions, "one link_pull_request per PR"); + assert.include(instructions, "every stack layer"); assert.include(instructions, "Skip PRs you only read or review"); assert.include(instructions, "If linking fails, say so."); assert.notInclude(instructions, "git log -1"); diff --git a/packages/provider-core/src/server/runtimeInstructions.ts b/packages/provider-core/src/server/runtimeInstructions.ts index f6118a7cd9cd..7f7dba43c2b0 100644 --- a/packages/provider-core/src/server/runtimeInstructions.ts +++ b/packages/provider-core/src/server/runtimeInstructions.ts @@ -1,5 +1,5 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` -Link each PR you create, take over, or push to: one link_pull_request per PR, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch. +Link each PR you create, take over, or push to: link_pull_request, or link_pull_requests for several, full URL, every stack layer, as your next call. Host tools do not link for you. If linking fails, say so. Skip PRs you only read or review, and PRs named only as background. Call watch_pull_request when asked to monitor or babysit, or when work you were asked to do, such as a merge, still waits on checks or reviews, even after an earlier unwatch. Finish any needed branch update and push first, then watch and end your turn. Otherwise act now or hand back. Delegated children never watch, even if the brief says babysit. After PR work, a child's last line is: PR , head , checks and reviews . Links and watches persist across pushes. For PR waits, no sleep, loops, gh --watch, Monitor or ScheduleWakeup. Before handback, call unwatch_pull_request on any PR you watch. `; /** Omit model and effort when the harness manages them dynamically. */ diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index fd3f1621bb03..1b07ede90c45 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -94,6 +94,11 @@ const T3_MCP_TOOLS: Readonly> = { "link-pr", "pull-request", ), + link_pull_requests: tool( + ["Link", "Linking", "Linked", "pull requests"], + "link-pr", + "pull-request", + ), unlink_pull_request: tool( ["Unlink", "Unlinking", "Unlinked", "a pull request"], "unlink-pr", diff --git a/packages/shared/src/toolOutput.test.ts b/packages/shared/src/toolOutput.test.ts index e54c9408cde3..e09e05b891fd 100644 --- a/packages/shared/src/toolOutput.test.ts +++ b/packages/shared/src/toolOutput.test.ts @@ -31,11 +31,13 @@ describe("compactDynamicToolOutput", () => { summary: "A child failed; the tool itself succeeded.", unlinked: [{ repository: "acme/app", state: "merged", numbers: [1] }], unlinkedCount: 1, + linkedCount: 2, }; expect(compactDynamicToolOutput(output)).toEqual({ thread: { threadId: "thread-1" }, taskId: "task-1", scheduledTaskId: "scheduled-1", + linkedCount: 2, unlinkedCount: 1, }); expect(output.thread.messages).toEqual(["Private message"]); diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index 8b07deee54da..05bb49fc6a5e 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -43,6 +43,7 @@ interface CompactToolOutput { [MCP_APP_OUTPUT_KEY]?: McpAppReference; thread?: { threadId: string }; threads?: Array<{ threadId?: string; status?: "rolled_back" }>; + linkedCount?: number; unlinkedCount?: number; } @@ -124,8 +125,10 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un if (id !== undefined) output[key] = id; } if (data.status === "rolled_back") output.status = "rolled_back"; - if (typeof data.unlinkedCount === "number" && Number.isSafeInteger(data.unlinkedCount)) - output.unlinkedCount = data.unlinkedCount; + for (const key of ["linkedCount", "unlinkedCount"] as const) { + const count = data[key]; + if (typeof count === "number" && Number.isSafeInteger(count)) output[key] = count; + } const htmlRender = readHtmlRenderReference(data.htmlRender); if (htmlRender !== undefined) output.htmlRender = htmlRender; const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]); From 9f9eaff56ebbbc980d28a88083bdfd1fafe7a0a0 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 1 Nov 2026 03:19:10 +0100 Subject: [PATCH 032/158] perf(server): task waits skip unrelated sibling updates (#27920) --- .../src/mcp/OrchestratorMcpService.test.ts | 151 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 23 ++- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 7617577422b0..dc8feb2bef29 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -19,11 +19,13 @@ import { RunId, ThreadId, type OrchestrationV2ServerCommand, + type OrchestrationV2StoredEvent, type OrchestrationV2ThreadProjection, type ServerProvider, } from "@t3tools/contracts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -1128,6 +1130,155 @@ describe("OrchestratorMcpService provider resolution", () => { turnItems: [], } as unknown as OrchestrationV2ThreadProjection; + it.effect.each([ + "parent completion", + "nested child completion", + "child run completion", + "child question", + "child approval", + ] as const)("a task wait still returns on %s", (wake) => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const subscribed = yield* Deferred.make(); + const nestedId = NodeId.make("node:mcp-wait-nested"); + const childRunId = RunId.make("run:mcp-wait-child"); + const childRootId = NodeId.make("node:mcp-wait-child-root"); + const eventThreadId = wake === "parent completion" ? parentThreadId : childThreadId; + const eventType = + wake === "child run completion" + ? "run.updated" + : wake === "child question" || wake === "child approval" + ? "runtime-request.updated" + : "subagent.updated"; + const terminal = wake.endsWith("completion"); + let ready = false; + const task = () => ({ + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + providerInstanceId: codexInstanceId, + driver: "codex", + childThreadId, + model: "gpt-5.4", + status: ready && terminal ? "completed" : "running", + result: ready && wake === "parent completion" ? "done" : null, + }); + const child = () => + ({ + ...childProjection, + runs: [ + { + id: childRunId, + ordinal: 1, + status: + wake === "nested child completion" || (ready && terminal) ? "completed" : "running", + startedAt: DateTime.makeUnsafe(0), + completedAt: ready && terminal ? DateTime.makeUnsafe(1) : null, + }, + ], + subagents: + wake === "nested child completion" + ? [{ id: nestedId, status: ready ? "completed" : "running" }] + : [], + nodes: [{ id: childRootId, runId: childRunId }], + runtimeRequests: + ready && !terminal + ? [ + { + id: "request:mcp-wait-answer", + nodeId: childRootId, + kind: wake === "child question" ? "user_input" : "command", + status: "pending", + responseCapability: { type: "live" }, + createdAt: DateTime.makeUnsafe(0), + }, + ] + : [], + }) as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + assertProviderInstanceEnabled: () => Effect.void, + isProviderInstanceEnabledForProject: () => Effect.succeed(true), + getThreadRecords: (threadId) => + Effect.sync(() => (threadId === parentThreadId ? parentProjection([task()]) : child())), + getThreadEventSequence: () => Effect.succeed(0), + delegatedTaskResultPending: () => Effect.succeed(false), + streamStoredEventsFrom: (input) => + input?.threadId === eventThreadId && input.eventType === eventType + ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(events)), + ) + : Stream.never, + dispatch: () => + Effect.succeed({ + sequence: 1, + storedEvents: [{ event: { type: "subagent.updated", payload: task() } }], + } as never), + }), + ServerSettings.layerTest(), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const waiting = yield* service + .delegateTask(scope, { task: "Review the change.", mode: "wait" }) + .pipe(Effect.forkChild); + yield* Deferred.await(subscribed); + ready = true; + if (wake === "parent completion") { + yield* Queue.offer(events, { + sequence: 2, + event: { + type: "subagent.updated", + threadId: parentThreadId, + payload: { ...task(), id: nestedId }, + }, + } as unknown as OrchestrationV2StoredEvent); + } + yield* Queue.offer(events, { + sequence: 3, + event: { + type: eventType, + threadId: eventThreadId, + payload: { id: wake === "parent completion" ? taskId : nestedId }, + }, + } as unknown as OrchestrationV2StoredEvent); + const result = yield* Fiber.join(waiting); + assert.isFalse(result.waitTimedOut); + if (terminal) { + assert.equal(result.status, "completed"); + if (wake === "parent completion") assert.equal(result.summary, "done"); + } else { + assert.equal(result.status, "running"); + if (wake === "child question") assert.isTrue(result.hasPendingQuestion); + else { + assert.deepEqual(result.pendingApproval, { + requestId: "request:mcp-wait-answer", + kind: "command", + runId: childRunId, + }); + } + } + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect( "advertises orchestration capability from registered adapters rather than a driver allowlist", () => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index a62a0cec644f..3836a547e726 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1791,12 +1791,23 @@ const make = Effect.gen(function* () { : TASK_WAKE_EVENTS; return yield* Stream.mergeAll( wakeEvents.map(({ thread, eventType }) => - threadManagement.streamStoredEventsFrom({ - threadId: - thread === "parent" || childThreadId === null ? scope.thread.threadId : childThreadId, - afterSequence, - eventType, - }), + threadManagement + .streamStoredEventsFrom({ + threadId: + thread === "parent" || childThreadId === null + ? scope.thread.threadId + : childThreadId, + afterSequence, + eventType, + }) + .pipe( + Stream.filter( + ({ event }) => + thread === "child" || + event.type !== "subagent.updated" || + event.payload.id === taskId, + ), + ), ), { concurrency: "unbounded" }, ).pipe( From b1bd06432db9c9cb795a3b4bba20370e8b6166c2 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 1 Nov 2026 02:19:25 +0000 Subject: [PATCH 033/158] fix(acp): keep turn spending out of context usage (#27927) Co-authored-by: Otavio Salvador <25278+otavio@users.noreply.github.com> --- .../Adapters/AcpRegistryAdapterV2.test.ts | 1 - packages/provider-acp/src/server/adapter.ts | 7 ++----- packages/provider-acp/src/server/runtimeModel.test.ts | 5 +---- packages/provider-acp/src/server/runtimeModel.ts | 10 ---------- 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 49ac36c55321..5a18843d709a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1477,7 +1477,6 @@ describe("AcpRegistryAdapterV2", () => { [ { usedTokens: 2_500, maxTokens: 10_000, cost: { amount: 0.42, currency: "USD" } }, { usedTokens: 2_500, maxTokens: 10_000, cost: { amount: 0.42, currency: "USD" } }, - { usedTokens: 3_000 }, ], ); assert.deepEqual(childThreads.at(-1)?.nativeMetadata, { diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index bd2552ab4218..f427ca605778 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -4616,10 +4616,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (yield* projectAgentTerminalUpdate(notification, context)) return; if ( update.sessionUpdate === "usage_update" || - update.sessionUpdate === "session_info_update" || - (update.sessionUpdate === "state_update" && - update.state === "idle" && - update.usage != null) + update.sessionUpdate === "session_info_update" ) { const stateEvent = parseSessionUpdateEvent(notification).events.find( (event) => event._tag === "UsageUpdated" || event._tag === "SessionInfoUpdated", @@ -4670,7 +4667,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( providerThread, }); } - if (update.sessionUpdate !== "state_update") return; + return; } if (yield* followNativeChild(notification, context)) return; if ( diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index b278c9e71bed..c35789ec47dc 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -1047,10 +1047,7 @@ describe("AcpRuntimeModel", () => { data: { rawOutput: "Retained the implementation decisions." }, }, }); - expect(idle.events[0]).toMatchObject({ - _tag: "UsageUpdated", - usage: { usedTokens: 420 }, - }); + expect(idle.events).toEqual([]); }); it("applies ACP v2 agent-owned terminal snapshots and output chunks", () => { diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 9a34f7fa4057..63778b971e5a 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1549,16 +1549,6 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } - case "state_update": { - if (upd.state === "idle" && upd.usage !== undefined && upd.usage !== null) { - events.push({ - _tag: "UsageUpdated", - usage: { usedTokens: upd.usage.totalTokens }, - rawPayload: params, - }); - } - break; - } case "usage_update": { const currency = upd.cost?.currency.trim().slice(0, 32); events.push({ From 4e6b7d8447d3d2067747304e7a61e7d41f2464df Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:49:39 +0530 Subject: [PATCH 034/158] fix(web): explain when a theme hides environment artwork (#27936) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../settings/AppearanceSettingsPanel.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index 1c5812a2bda0..00783b085d60 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -19,6 +19,7 @@ import { useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { useTheme } from "../../hooks/useTheme"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -106,6 +107,20 @@ export function AppearanceSettingsPanel() { environmentIdentificationOptionList.find( ([value]) => value === settings.environmentIdentificationMode, )?.[1] ?? ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]; + const effectiveEnvironmentIdentificationMode = useEnvironmentIdentificationMode(); + const themeHidesEnvironmentArtwork = + effectiveEnvironmentIdentificationMode === "pill" && + resolveSidebarStageBackdropVariant( + environmentStageLabel, + settings.environmentIdentificationMode, + ) !== null; + const environmentIdentificationDescription = themeHidesEnvironmentArtwork + ? environmentStageVariant + ? "Only built-in themes show artwork, so this theme shows the version pill." + : "Only built-in themes show artwork." + : environmentStageVariant + ? "Choose how Dev and Nightly environments are identified." + : "Show Dev or Nightly artwork in the sidebar header."; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { @@ -395,11 +410,7 @@ export function AppearanceSettingsPanel() { /> Date: Sun, 1 Nov 2026 07:49:54 +0530 Subject: [PATCH 035/158] fix(mobile): Android commit sheet scrolls to the commit buttons (#28272) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../features/threads/git/GitCommitSheet.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 68ea161b0110..f833a2ec4ce6 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -67,24 +67,31 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { }); }; + const android = Platform.OS === "android"; + const header = ( + navigation.goBack()} + accessibilityLabel="Cancel commit" + /> + ); + return ( + // Material sheet drags follow the first nested-scrolling native view, and the inline + // header's Compose button is one, so Android renders content first and reverses the column. - {Platform.OS === "android" ? ( + {android ? ( ) : null} - navigation.goBack()} - accessibilityLabel="Cancel commit" - /> + {android ? null : header} + {android ? header : null} ); } From 481ff3ea89c7358953e8b7ffb762978c31fb3a0b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:20:09 -0500 Subject: [PATCH 036/158] fix(cursor): avoid reporting unfinished tool calls as successful (#21448) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/server/adapter.test.ts | 64 ++++++++++++++++++- .../provider-cursor/src/server/adapter.ts | 3 +- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 790dd0e4415e..5318e160f610 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -43,7 +43,7 @@ describe("CursorAdapterV2", () => { { status: "finished", model: undefined, lateModel: "gpt-6-sol" }, { status: "finished", model: "gpt-6-sol", lateModel: null }, ] as const)( - "projects Cursor tasks: $status, late model $lateModel", + "projects Cursor tasks and unfinished tools: $status, late model $lateModel", ({ status, model, lateModel }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -115,6 +115,34 @@ describe("CursorAdapterV2", () => { }, }).pipe(Effect.orDie); } + const readUpdates: ReadonlyArray = [ + { + type: "tool-call-started", + modelCallId: "native-model-call", + callId: "read-secret", + toolCall: { type: "read", args: { path: "secret.txt" } }, + }, + ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({ + type, + modelCallId: "native-model-call", + callId: "read-open", + toolCall: { + type: "read" as const, + args: { path: "open.txt" }, + ...(type === "tool-call-completed" + ? { + result: { + status: "success" as const, + value: { fileSize: 3, content: "ok\n", totalLines: 1 }, + }, + } + : {}), + }, + })), + ]; + for (const update of readUpdates) { + yield* input.onDelta!(update).pipe(Effect.orDie); + } return { agentId: "native-cursor-lifecycle", runId: "native-cursor-run", @@ -205,6 +233,40 @@ describe("CursorAdapterV2", () => { : "failed", ); assert.isNotNull(rows.at(-1)?.subagent.completedAt); + const unfinished = + status === "finished" ? "interrupted" : status === "error" ? "failed" : "cancelled"; + const finalReads = new Map(); + for (const event of events) { + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "Read" + ) { + finalReads.set(String(event.turnItem.title), { + status: event.turnItem.status, + output: event.turnItem.output, + done: event.turnItem.completedAt !== null, + }); + } + } + const finalNodes = new Map(); + for (const event of events) { + if (event.type === "node.updated" && event.node.kind === "tool_call") { + finalNodes.set(String(event.node.nativeItemRef?.nativeId), event.node.status); + } + } + assert.deepEqual(Object.fromEntries(finalReads), { + "Read secret.txt": { status: unfinished, output: undefined, done: true }, + "Read open.txt": { + status: "completed", + output: { fileSize: 3, content: "ok\n", totalLines: 1 }, + done: true, + }, + }); + assert.deepEqual(Object.fromEntries(finalNodes), { + "read-secret": unfinished, + "read-open": "completed", + }); }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 03f346d9a6d9..ab6afddb4b89 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2113,7 +2113,8 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( yield* emitToolArtifacts({ active: tool, completed: true, - ...(input.status === "completed" ? {} : { unfinishedStatus: input.status }), + // No completion event means the tool outcome is unknown. + unfinishedStatus: input.status === "completed" ? "interrupted" : input.status, }); } input.context.tools.clear(); From 0ed7b7252e2d2fc302e44caa9b3050df9f8b7b41 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Sat, 31 Oct 2026 22:20:24 -0400 Subject: [PATCH 037/158] fix(gitlab): canceling pipelines no longer show as pending (#21543) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/server/gitLabMergeRequestJson.test.ts | 8 ++++++++ .../src/server/gitLabMergeRequestJson.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index 74403b4dae9a..870fe42df4ff 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -148,6 +148,14 @@ describe("decodeMergeRequestDetailJson", () => { ]); }); + it.each(["canceled", "canceling"])("maps a %s pipeline to cancelled", (status) => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status } })), + ); + + expect(detail.checks[0]?.status).toBe("cancelled"); + }); + it("reads an uncounted change set as its floor", () => { const detail = expectSuccess( decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 8f50d9ea6659..e8e5e664b372 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -262,7 +262,7 @@ function toPipelineStatus(value: string | null | undefined): PullRequestCheckSta case "failed": return "failure"; case "canceled": - case "cancelling": + case "canceling": return "cancelled"; case "skipped": return "skipped"; From 9b1802d4d6f1e86ccfd140c4a7abf7e188987e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Biro=C5=A1?= Date: Sun, 1 Nov 2026 03:20:39 +0100 Subject: [PATCH 038/158] docs: explain how to enable the context window indicator (#21678) Co-authored-by: maria-rcks --- docs/user/composer.md | 7 ++++++- docs/user/providers-acp.md | 4 +++- docs/user/providers-claude.md | 8 ++++++-- docs/user/providers-pi.md | 5 +++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/user/composer.md b/docs/user/composer.md index db41f6cc013f..9f13b44c48dc 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -240,7 +240,12 @@ Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. Send `/compact` in an existing conversation to reduce context usage when the -provider supports it. Web and desktop also offer compaction from the context meter. +provider supports it, even with the context meter turned off. + +On web and desktop, turn on **Settings → Labs → Legacy → Context +window indicator (legacy)** to see context usage. The meter is off by default +and appears once the provider reports usage. Open it for usage details and +**Compact context** when the provider supports compaction. Claude instances can also compact idle threads for you. See [compact long conversations](./providers-claude.md#compact-long-conversations). diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index 120df9c6141f..ab660b746cae 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -100,7 +100,9 @@ including when another client performs the import. The thread keeps the native session's title and last-update value as provider metadata without overwriting a title you set in T3 Code. Agents that report ACP context usage drive the composer's -context meter and cumulative cost metadata. Text resources and links render as assistant output; +context meter and cumulative cost metadata. The meter is off by default; see +[commands and skills](./composer.md#commands-and-skills) to enable it on web and desktop. +Text resources and links render as assistant output; binary resources, images, and audio that T3 Code cannot render yet appear as explicit placeholders instead of disappearing. diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 206d82ce3233..c94fc3420f87 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -49,8 +49,12 @@ Set **Auto-compact after** in the Claude provider settings to an integer between window. Leave it empty for Claude Code's default. You can also send `/compact` in an existing conversation. Web and desktop offer -**Compact context** from the context meter. When you return to a large thread -after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows +**Compact context** from the context meter. The meter is off by default; see +[commands and skills](./composer.md#commands-and-skills) to enable it. Claude Code +compacts automatically whether the meter is on or off. + +When you return to a large thread after Claude's prompt cache expires, a +**Compact** chip with the thread's token count shows next to the send button. While it is on, Enter summarizes the history first, then sends your message. Click the chip to switch it to **Full** and keep the full history for that message. The cache lasts about an hour on a Claude diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index e106d0afdd38..37026d50ab76 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -86,5 +86,6 @@ threads. environment, including `PI_CODING_AGENT_DIR` if set. Trust the folder or its parent with `/trust`, then start a new thread using that same worktree. Launch arguments with `--no-approve` keep project files off without the notice. -- The context meter appears once Pi reports usage for the thread. Some model providers only - report usage when a response completes, so the meter can wait for the first reply. +- The context meter is off by default; see [commands and skills](./composer.md#commands-and-skills) + to enable it on web and desktop. It appears once Pi reports usage for the thread. Some model + providers only report usage when a response completes, so the meter can wait for the first reply. From 43d445b408fb0f612f17388fdc8cdbd2f2e1f24c Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Sun, 1 Nov 2026 03:20:54 +0100 Subject: [PATCH 039/158] fix(web): file preview videos keep their own aspect ratio (#21839) Co-authored-by: maria-rcks --- apps/web/src/components/files/FilePreviewPanel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 234f077da942..b88d92fc4edc 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -322,6 +322,7 @@ function WorkspaceVideoPreview(props: { revision={revision} preload="metadata" className="flex h-full min-h-0 w-full max-w-5xl items-center justify-center" + videoClassName="aspect-auto max-h-full w-auto max-w-full" onRetry={refreshAssetUrl} actionsSource={{ kind: "video", From 08a40a9d6e5746a8b35ec99af824c6595fa2c6fd Mon Sep 17 00:00:00 2001 From: AdEx-Partners-DE Date: Sun, 1 Nov 2026 03:21:09 +0100 Subject: [PATCH 040/158] fix(server): an open question stays in the thread window, so its answer panel shows (#22291) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ProjectionStore.test.ts | 170 ++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 15 ++ .../orchestration-v2/threadHistoryPaging.ts | 29 ++- 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 2f7c9f00b4e6..9bd3566e559a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2885,6 +2885,176 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect("keeps an open question's row in the window after the run kept working", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const nowIso = DateTime.formatIso(now); + const threadId = ThreadId.make("thread:open-question-window"); + yield* projectionStore.apply({ + id: EventId.make("event:open-question-window:thread"), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:open-question-window"), + title: "Open question window", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + for (let ordinal = 10; ordinal <= 300; ordinal += 1) { + const id = `turn-item:open-question-window:${ordinal}`; + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, + parent_item_id, ordinal, type, status, updated_at, payload_json + ) VALUES ( + ${id}, ${threadId}, NULL, NULL, NULL, NULL, NULL, ${ordinal}, + 'command_execution', 'completed', ${nowIso}, + ${encodeUnknownJsonString({ + id, + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: `command ${ordinal}`, + input: `echo ${ordinal}`, + output: "ok", + exitCode: 0, + startedAt: nowIso, + completedAt: nowIso, + updatedAt: nowIso, + type: "command_execution", + })} + ) + `; + } + const requestId = RuntimeRequestId.make("request:open-question-window"); + const nodeId = NodeId.make("node:open-question-window"); + const itemId = TurnItemId.make("turn-item:open-question-window:question"); + yield* projectionStore.apply({ + id: EventId.make("event:open-question-window:node"), + type: "node.updated", + threadId, + nodeId, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId: null, + parentNodeId: null, + rootNodeId: nodeId, + kind: "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + const request = { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + responseCapability: { type: "message" as const }, + createdAt: now, + resolvedAt: null, + }; + yield* projectionStore.apply({ + id: EventId.make("event:open-question-window:request"), + type: "runtime-request.updated", + threadId, + nodeId, + occurredAt: now, + payload: request, + }); + yield* projectionStore.apply({ + id: EventId.make("event:open-question-window:item"), + type: "turn-item.updated", + threadId, + nodeId, + occurredAt: now, + payload: { + id: itemId, + type: "user_input_request", + threadId, + runId: null, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 5, + status: "waiting", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + requestId, + responseMode: "message", + questions: [{ id: "choice", header: "Choice", question: "Continue?", options: [] }], + }, + }); + + const questionRows = (snapshot: { + readonly projection: { readonly turnItems: ReadonlyArray<{ readonly id: string }> }; + }) => snapshot.projection.turnItems.filter((item) => item.id === itemId).length; + const open = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: 76 }); + assert.strictEqual(questionRows(open), 1); + const bounded = buildBoundedThreadProjection({ + policy: { ...THREAD_HISTORY_PAGE_POLICY, maxUserTurns: undefined, maxItems: 50 }, + projection: open.projection, + snapshotSequence: open.snapshotSequence, + }); + assert.strictEqual(questionRows(bounded), 1); + assert.isFalse( + bounded.projection.visibleTurnItems.some((row) => row.sourceItemId === itemId), + ); + + yield* projectionStore.apply({ + id: EventId.make("event:open-question-window:resolved"), + type: "runtime-request.updated", + threadId, + nodeId, + occurredAt: now, + payload: { ...request, status: "resolved", resolvedAt: now }, + }); + const answered = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: 76 }); + assert.strictEqual(questionRows(answered), 0); + }), + ); + it.effect("scopes actionable provider state to the requested bounded thread", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index e2481acda8a0..95c0f7a4bae5 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2967,6 +2967,21 @@ export const layer: Layer.Layer = ) AND request.type = 'run_interrupt_request' UNION + -- An open question or approval stays in the window: clients + -- build the answer panel from its row, and a run that kept + -- working pushes the row out of the newest turns. + SELECT question.rowid AS item_rowid, question.ordinal, question.turn_item_id + FROM orchestration_v2_projection_runtime_requests AS pending + JOIN orchestration_v2_projection_turn_items AS question + ON question.node_id = pending.node_id + WHERE pending.thread_id = ${threadId} + AND pending.status = 'pending' + AND question.thread_id = ${threadId} + AND question.type IN ('user_input_request', 'approval_request') + AND ${window.rowLimit} > 0 + AND ${window.anchorItemId ?? null} IS NULL + AND ${window.requiredRunId ?? null} IS NULL + UNION SELECT latest.item_rowid, latest.ordinal, latest.turn_item_id FROM ( SELECT rowid AS item_rowid, ordinal, turn_item_id diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 7f1ea4db95d6..03d056531a76 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -501,6 +501,29 @@ function isLocalProjectedRow( return row.visibility === "local" || String(row.sourceThreadId) === String(projection.thread.id); } +function pendingRequestIdsOf( + projection: Pick, +): ReadonlySet { + return new Set( + projection.runtimeRequests + .filter((request) => request.status === "pending") + .map((request) => String(request.id)), + ); +} + +// Clients build the answer panel of an open question or approval from its row, +// and a run that kept working pushes that row out of the newest turns. +function isRetainedDependency( + item: OrchestrationV2TurnItem, + pendingRequestIds: ReadonlySet, +): boolean { + return ( + item.type === "run_interrupt_request" || + ((item.type === "user_input_request" || item.type === "approval_request") && + pendingRequestIds.has(String(item.requestId))) + ); +} + /** Retain small requests outside the visible window; later result pages still need them for attempt/run reducers. */ function retainedInterruptRequestTurnItems( projection: OrchestrationV2ThreadProjection, @@ -513,9 +536,10 @@ function retainedInterruptRequestTurnItems( } } + const pendingRequestIds = pendingRequestIdsOf(projection); const retained: OrchestrationV2TurnItem[] = []; for (const item of projection.turnItems) { - if (item.type !== "run_interrupt_request") { + if (!isRetainedDependency(item, pendingRequestIds)) { continue; } if (visibleLocalIds.has(String(item.id))) { @@ -635,8 +659,9 @@ export function buildBoundedThreadProjection(input: { const dependencyReserve = (() => { let reserve = 0; + const pendingRequestIds = pendingRequestIdsOf(controlProjection); for (const item of controlProjection.turnItems) { - if (item.type === "run_interrupt_request") { + if (isRetainedDependency(item, pendingRequestIds)) { reserve += bytesOfJson(item); } } From 9cb59bf8587daebfa762e35b9d7ca3bc947d0e56 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Sat, 31 Oct 2026 22:21:24 -0400 Subject: [PATCH 041/158] fix(web): mod+L focuses the browser address bar from a focused page (#22328) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/desktop/src/preview/Manager.test.ts | 21 ++++++++- apps/desktop/src/preview/Manager.ts | 3 +- .../ReopenClosedViewShortcut.browser.test.tsx | 7 +++ .../components/ReopenClosedViewShortcut.tsx | 5 ++- .../preview/PreviewView.browser.test.tsx | 43 +++++++++++++++++++ .../src/components/preview/PreviewView.tsx | 9 +++- 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 41ac8abd3782..d917af72d389 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -619,7 +619,8 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { const send = vi.fn(); - const host = { isDestroyed: () => false, send }; + const focus = vi.fn(); + const host = { isDestroyed: () => false, send, focus }; const older = makeFaviconWebContents({ id: 42 }); const newer = makeFaviconWebContents({ id: 43 }); for (const tab of [older, newer]) Object.assign(tab.webContents, { hostWebContents: host }); @@ -648,6 +649,22 @@ describe("PreviewManager", () => { expect(preventDefault).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "view.reopenClosed"); + yield* manager.setForwardedShortcuts([ + { command: "preview.focusUrl", shortcut: parseKeybindingShortcut("mod+l")! }, + ]); + beforeInput({ preventDefault } as never, { ...key, key: "l", shift: false } as never); + expect(focus).toHaveBeenCalledOnce(); + expect(send).toHaveBeenLastCalledWith("desktop:menu-action", "preview.focusUrl"); + expect(focus.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]!); + beforeInput( + { preventDefault } as never, + { ...key, key: "l", shift: false, isAutoRepeat: true } as never, + ); + expect(focus).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledTimes(2); + send.mockClear(); + yield* manager.setForwardedShortcuts([{ command: "view.reopenClosed", shortcut }]); + Object.assign(newer.webContents, { focusedFrame: null }); const agentInput = newer.listeners.get("before-input-event")!; const agentPreventDefault = vi.fn(); @@ -663,7 +680,7 @@ describe("PreviewManager", () => { { ...key, key: "T", shift: true } as never, ); expect(agentPreventDefault).not.toHaveBeenCalled(); - expect(send).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index dc05f1a8c82e..0087444a8412 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1478,7 +1478,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!input.isAutoRepeat) { if ( forwarded.command === "workspace.focusNext" || - forwarded.command === "workspace.focusPrevious" + forwarded.command === "workspace.focusPrevious" || + forwarded.command === "preview.focusUrl" ) { // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. host.focus(); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index c388ec33af0b..5dc65e57f1ca 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -265,4 +265,11 @@ describe("root reopen shortcut", () => { expect(useClosedViewStore.getState().entries).toEqual([]); }, ); + it("forwards address-bar focus from a focused desktop page", async () => { + setup(); + await using _shortcut = await renderShortcut(); + expect(state.setShortcuts).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ command: "preview.focusUrl" })]), + ); + }); }); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index ce187f5db5e9..77cc7b3d3068 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -43,12 +43,13 @@ import { } from "../threadRoutes"; import { toastManager } from "./ui/toast"; -const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ +const PREVIEW_FORWARDED_COMMANDS = [ "workspace.focusNext", "workspace.focusPrevious", "sidebar.toggle", "rightPanel.toggle", "rightPanel.toggleMaximized", + "preview.focusUrl", ] as const; const isGlobalPullRequests = (ref: ScopedThreadRef) => @@ -205,7 +206,7 @@ export function ReopenClosedViewShortcut() { // Guest browser processes miss app keydown listeners; desktop forwards the chords as menu actions. const commands = [ ...(hasHistory ? (["view.reopenClosed"] as const) : []), - ...PREVIEW_FORWARDED_LAYOUT_COMMANDS, + ...PREVIEW_FORWARDED_COMMANDS, ]; void preview .setForwardedShortcuts( diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 43f7845d10db..cdc49b336f47 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -286,6 +286,49 @@ describe("PreviewView", () => { expect(mocks.addImage).not.toHaveBeenCalled(); }); + it("focuses and selects the address from forwarded desktop actions only while visible", async () => { + setup(); + using stack = new DisposableStack(); + const previousBridge = window.desktopBridge; + stack.defer(() => { + if (previousBridge) window.desktopBridge = previousBridge; + else delete window.desktopBridge; + }); + const listeners = new Set<(action: string) => void>(); + window.desktopBridge = { + getWindowZoomFactor: () => 1, + onMenuAction: (listener: (action: string) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + } as NonNullable; + await using screen = await render(view()); + const address = page.getByRole("textbox"); + const input = address.element() as HTMLInputElement; + const forward = () => + act(() => { + listeners.forEach((listener) => listener("preview.focusUrl")); + }); + + await forward(); + await expect.element(address).toHaveFocus(); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + await act(() => input.blur()); + await forward(); + await expect.element(address).toHaveFocus(); + + await act(() => input.blur()); + await screen.rerender( + , + ); + await forward(); + await expect.element(address).not.toHaveFocus(); + expect(listeners.size).toBe(0); + }); + it("focuses the address bar each time the focus-url shortcut fires", async () => { setup(); await using _screen = await render(view()); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 5126b18ec7a9..865448e1f6dc 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -860,7 +860,14 @@ export function PreviewView({ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview actions while the view is visible useEffect(() => { if (!visible) return; - return subscribePreviewAction((action) => onPreviewAction(action)); + const unsubscribeAction = subscribePreviewAction((action) => onPreviewAction(action)); + const unsubscribeMenu = window.desktopBridge?.onMenuAction((action) => { + if (action === "preview.focusUrl") onPreviewAction("focus-url"); + }); + return () => { + unsubscribeAction(); + unsubscribeMenu?.(); + }; }, [visible]); return ( From 706bf34621a3e16283cf392a755d42dc79dadb23 Mon Sep 17 00:00:00 2001 From: Claudesaul <139932571+Claudesaul@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:21:39 -0400 Subject: [PATCH 042/158] fix(mobile): iOS terminal's Attach visible output clears the home indicator (#22418) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/features/terminal/ThreadTerminalRouteScreen.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index a2a2450864ee..016b75cfb305 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -976,7 +976,14 @@ function ThreadTerminal(props: { KeyboardController.dismiss(); requestCapture(); }} - className="px-4 py-2" + className="px-4 pt-2" + style={{ + paddingBottom: + 8 + + (keyboardState.isVisible + ? 0 + : (nativeColumnMetrics?.safeArea.bottom ?? insets.bottom)), + }} > Attach visible output From 253acdeaf91b2270d5098f0d583c0e743cde454d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:21:54 -0500 Subject: [PATCH 043/158] fix(pi): show an extension dialog's title once (#22463) Co-authored-by: Claude Opus 5.5 --- .../provider-pi/src/server/adapter.test.ts | 32 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 12 +++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 56754c30923b..face22d8c2fa 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -3301,6 +3301,34 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("keeps an input dialog's title as the header above its placeholder", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-input-placeholder", + method: "input", + title: "Name", + placeholder: "Your name", + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { + return assert.fail("expected a user input request"); + } + assert.equal(event.turnItem.questions[0]?.header, "Name"); + assert.equal(event.turnItem.questions[0]?.question, "Your name"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("emits a valid empty select option and returns its original value", () => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -3332,6 +3360,8 @@ describe("PiAdapterV2", () => { ["Empty value", ""], ], ); + assert.equal(questions[0]?.header, "Question"); + assert.equal(questions[0]?.question, "Pick a tag"); yield* runtime.respondToRuntimeRequest({ requestId: event.turnItem.requestId, answers: { "ui-select": "" }, @@ -3366,6 +3396,7 @@ describe("PiAdapterV2", () => { if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { return assert.fail("expected a user input request"); } + assert.equal(decodeUserInputQuestions(event.turnItem.questions)[0]?.header, "Question"); assert.equal(decodeUserInputQuestions(event.turnItem.questions)[0]?.question, title); yield* runtime.respondToRuntimeRequest({ requestId: event.turnItem.requestId, @@ -3401,6 +3432,7 @@ describe("PiAdapterV2", () => { return; assert.equal(event.turnItem.questions[0]?.initialAnswer, prefill); assert.deepEqual(event.turnItem.questions[0]?.options, []); + assert.equal(event.turnItem.questions[0]?.header, "Question"); assert.equal(event.turnItem.questions[0]?.question, "Commit message"); const edited = " fix(server): edited commit message\n\n"; yield* runtime.respondToRuntimeRequest({ diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index f1924e763920..a32a5759ba70 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3333,15 +3333,15 @@ function piQuestion( value: "", }, ]; + // Pi's select and editor dialogs carry only a title, so show it once, as the question. // Extensions may send a blank placeholder, which is not a valid question. - const question = - [recordString(event, "message"), recordString(event, "placeholder")].find( - (text) => text !== undefined && text.trim().length > 0, - ) ?? title; + const detail = [recordString(event, "message"), recordString(event, "placeholder")].find( + (text) => text !== undefined && text.trim().length > 0, + ); return { id: questionId, - header: title, - question, + header: detail === undefined ? "Question" : title, + question: detail ?? title, ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), options, }; From 655165d5f8053d1008bbaf9d0015f8f637275c3d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:22:10 -0500 Subject: [PATCH 044/158] fix(server): generated PR text reads source diffs instead of textconv output (#22503) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 33 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 12 +++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8363e16c3247..a89863d4f7f4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2577,6 +2577,39 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reads range patches from source without running textconv", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "diff.probe.textconv", "touch textconv-ran; echo converted"]); + yield* git(cwd, ["checkout", "-b", "feature/range"]); + yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); + yield* writeTextFile(cwd, "notes.doc", "source line\n"); + yield* writeTextFile(cwd, "plain.txt", "plain line\n"); + yield* writeTextFile(cwd, "binary.dat", "binary\0data"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "range files"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const context = yield* driver.readRangeContext(cwd, initialBranch); + + assert.include(context.diffPatch, "+source line"); + assert.notInclude(context.diffPatch, "converted"); + assert.include(context.diffPatch, "+plain line"); + assert.include(context.diffPatch, "Binary files /dev/null and b/binary.dat differ"); + assert.include(context.diffSummary, "notes.doc"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); + + yield* writeTextFile(cwd, "notes.doc", "updated source line\n"); + const staged = yield* driver.prepareCommitContext(cwd, ["notes.doc"]); + assert.include(staged?.stagedPatch ?? "", "+updated source line"); + assert.notInclude(staged?.stagedPatch ?? "", "converted"); + assert.include(staged?.stagedSummary ?? "", "notes.doc"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); + }), + ); + it.effect("loads new and deleted files without reading their missing side", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 71945b0b6f71..bea65a0f42a8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2502,7 +2502,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + ["diff", "--no-ext-diff", "--no-textconv", "--cached", "--patch", "--minimal"], { env, maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, @@ -2913,7 +2913,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", "--end-of-options", diffRange], + [ + "diff", + "--no-ext-diff", + "--no-textconv", + "--patch", + "--minimal", + "--end-of-options", + diffRange, + ], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, From 5731c0222a977656ea96f3bf8082f0a7007d0ed8 Mon Sep 17 00:00:00 2001 From: F4llen <76191805+f4llenz@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:22:25 -0400 Subject: [PATCH 045/158] fix(server): queued delegated completions keep a newer model choice (#22579) --- .../src/orchestration-v2/Orchestrator.ts | 4 +- .../src/orchestration-v2/runtimeLayer.test.ts | 110 ++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index db398ebf8aad..5f9c6ea3993e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -767,7 +767,8 @@ function followsCurrentConversation( message: OrchestrationV2ConversationMessage | undefined, ): boolean { return ( - message?.notification?.source.kind === "monitor" && !isBufferedProviderNotification(message) + message?.delegatedCompletion !== undefined || + (message?.notification?.source.kind === "monitor" && !isBufferedProviderNotification(message)) ); } @@ -776,7 +777,6 @@ function queuedRunModelSelection( run: OrchestrationV2Run, ): ModelSelection { const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); - // App-owned monitor updates follow the agent currently handling the thread. return followsCurrentConversation(message) ? projection.thread.modelSelection : run.modelSelection; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7b4a24219b2c..8e1ca75fcac9 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5714,6 +5714,116 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([ + { ...modelSelection, model: "gpt-5.5", options: [{ id: "reasoningEffort", value: "high" }] }, + { instanceId: alternateInstanceId, model: "gpt-5.5" }, + ])( + "starts a queued delegated completion on a selection set while it waited: %j", + (newerSelection) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make( + `runtime-layer-queued-delegated-completion-selection-${newerSelection.instanceId}`, + ); + const taskId = NodeId.make(`${threadId}:task`); + const completionMessageId = MessageId.make(`${threadId}:completion`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Delegated completion selection", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Delegate a task", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:cohort`), + type: "run.updated", + threadId, + runId: active.id, + occurredAt: now, + payload: { + ...active, + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { messageId: completionMessageId, generation: 1, taskIds: [taskId] }, + }, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:completion`), + threadId, + messageId: completionMessageId, + text: "Delegated task finished", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + delegatedCompletion: { parentRunId: active.id, generation: 1, taskIds: [taskId] }, + }); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-newer`), + threadId, + modelSelection: newerSelection, + }); + const waiting = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:completed`), + type: "run.updated", + threadId, + runId: active.id, + occurredAt: now, + payload: { + ...waiting.runs.find((run) => run.id === active.id)!, + status: "completed", + completedAt: now, + }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === completionMessageId)!; + assert.equal(delivered.status, "starting"); + assert.deepEqual(delivered.modelSelection, newerSelection); + assert.equal( + after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) + ?.providerInstanceId, + newerSelection.instanceId, + ); + assert.deepEqual(after.thread.modelSelection, newerSelection); + }), + ); + it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 40f0649b639766964256351c4bdd977488ea5a6b Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Sat, 31 Oct 2026 23:22:41 -0300 Subject: [PATCH 046/158] fix(acp): turn token spend no longer blocks provider handoffs (#22608) From 35297230008beda7d7257b3d365bba964579e69e Mon Sep 17 00:00:00 2001 From: Yoru <0tox@iterelle.tech> Date: Sat, 31 Oct 2026 19:22:57 -0700 Subject: [PATCH 047/158] fix(desktop): skip auxiliary macOS snapshot windows (#22624) Co-authored-by: maria-rcks --- .../desktop/src/snapShot/ActiveWindow.test.ts | 202 ++++++++++++++++++ apps/desktop/src/snapShot/ActiveWindow.ts | 93 +++++--- 2 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/snapShot/ActiveWindow.test.ts diff --git a/apps/desktop/src/snapShot/ActiveWindow.test.ts b/apps/desktop/src/snapShot/ActiveWindow.test.ts new file mode 100644 index 000000000000..fbe96ff92535 --- /dev/null +++ b/apps/desktop/src/snapShot/ActiveWindow.test.ts @@ -0,0 +1,202 @@ +import { assert, beforeEach, it, vi } from "vite-plus/test"; + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); + +vi.mock("node:child_process", () => ({ execFile: execFileMock })); + +import { activeWindow } from "./ActiveWindow.ts"; + +beforeEach(() => { + execFileMock.mockReset(); +}); + +function stubMacLookup(stdout: string) { + execFileMock.mockImplementation( + ( + _file: string, + _args: ReadonlyArray, + _options: unknown, + callback: (error: Error | null, stdout: string) => void, + ) => callback(null, stdout), + ); +} + +function macLookup( + windows: ReadonlyArray<{ + id: number; + title: string; + bounds: { x: number; y: number; width: number; height: number }; + alpha?: number; + }>, +) { + return JSON.stringify({ + owner: { + name: "Editor", + processId: 123, + path: "/Applications/Editor.app", + bundleId: "com.example.editor", + }, + windows: windows.map((window) => ({ ...window, alpha: window.alpha ?? 1 })), + }); +} + +it("parses the frontmost macOS window from the osascript lookup", async () => { + stubMacLookup( + macLookup([ + { + id: 42, + title: "main.ts", + bounds: { x: 10, y: 20, width: 800, height: 600 }, + }, + ]) + "\n", + ); + + const window = await activeWindow("darwin"); + + assert.deepEqual(window, { + platform: "macos", + id: 42, + title: "main.ts", + bounds: { x: 10, y: 20, width: 800, height: 600 }, + owner: { + name: "Editor", + processId: 123, + path: "/Applications/Editor.app", + bundleId: "com.example.editor", + }, + }); + const [file, args] = execFileMock.mock.calls[0]!; + assert.strictEqual(file, "/usr/bin/osascript"); + assert.deepEqual(args.slice(0, 3), ["-l", "JavaScript", "-e"]); +}); + +it.each([ + { + name: "transparent and untitled helpers", + helpers: [ + { + id: 1, + title: "", + bounds: { x: -1, y: -44, width: 1_680, height: 44 }, + alpha: 0, + }, + { id: 2, title: "", bounds: { x: 0, y: 0, width: 1_680, height: 68 } }, + ], + content: { + id: 3, + title: "Document", + bounds: { x: 0, y: 0, width: 1_680, height: 1_050 }, + }, + }, + { + name: "Chrome strips stacked above the content window", + helpers: [ + { id: 75, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 41 } }, + { id: 74, title: "", bounds: { x: 0, y: 80, width: 1_800, height: 81 } }, + { id: 1691, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 158 } }, + ], + content: { + id: 71, + title: "(10) Video - YouTube", + bounds: { x: 0, y: 161, width: 1_800, height: 1_008 }, + }, + }, + { + name: "vertical strips at a negative desktop origin", + helpers: [ + { id: 1, title: "", bounds: { x: -122, y: -100, width: 41, height: 1_000 } }, + { id: 2, title: "", bounds: { x: -81, y: -100, width: 81, height: 1_000 } }, + ], + content: { + id: 3, + title: "Document", + bounds: { x: 0, y: -100, width: 1_008, height: 1_000 }, + }, + }, + { + name: "a smaller front window's strips with a larger background window", + helpers: [ + { id: 75, title: "", bounds: { x: 100, y: 139, width: 1_200, height: 41 } }, + { id: 74, title: "", bounds: { x: 100, y: 180, width: 1_200, height: 81 } }, + ], + content: { + id: 71, + title: "Video - YouTube", + bounds: { x: 100, y: 261, width: 1_200, height: 700 }, + }, + background: [ + { id: 80, title: "Other document", bounds: { x: 0, y: 39, width: 1_800, height: 1_130 } }, + ], + }, +])("selects the content window behind $name", async ({ helpers, content, background = [] }) => { + stubMacLookup(macLookup([...helpers, content, ...background])); + + const window = await activeWindow("darwin"); + + assert.strictEqual(window?.id, content.id); + assert.deepEqual(window?.bounds, content.bounds); +}); + +it.each([ + { name: "untitled dialog", title: "", bounds: { x: 300, y: 200, width: 400, height: 240 } }, + { name: "titled palette", title: "Palette", bounds: { x: 0, y: 0, width: 800, height: 50 } }, + { name: "offset untitled strip", title: "", bounds: { x: 100, y: 100, width: 800, height: 50 } }, + { name: "tall untitled window", title: "", bounds: { x: 0, y: 0, width: 800, height: 900 } }, + { + name: "horizontal strip below the document", + title: "", + bounds: { x: 0, y: 1_200, width: 800, height: 50 }, + }, + { + name: "horizontal strip inside the document", + title: "", + bounds: { x: 0, y: 600, width: 800, height: 50 }, + }, + { + name: "horizontal strip far above the document", + title: "", + bounds: { x: 0, y: -300, width: 800, height: 50 }, + }, + { + name: "vertical strip right of the document", + title: "", + bounds: { x: 900, y: 0, width: 50, height: 1_000 }, + }, + { + name: "vertical strip inside the document", + title: "", + bounds: { x: 400, y: 0, width: 50, height: 1_000 }, + }, + { + name: "vertical strip far left of the document", + title: "", + bounds: { x: -250, y: 0, width: 50, height: 1_000 }, + }, +])("keeps a frontmost $name ahead of a larger document", async ({ title, bounds }) => { + stubMacLookup( + macLookup([ + { id: 1, title, bounds }, + { id: 2, title: "Document", bounds: { x: 0, y: 0, width: 800, height: 1_000 } }, + ]), + ); + + const window = await activeWindow("darwin"); + + assert.strictEqual(window?.id, 1); + assert.deepEqual(window?.bounds, bounds); +}); + +it("keeps an untitled content window ahead of a much taller background window", async () => { + const bounds = { x: 0, y: 0, width: 800, height: 900 }; + stubMacLookup( + macLookup([ + { id: 1, title: "", bounds }, + { id: 2, title: "Background document", bounds: { x: 0, y: 0, width: 800, height: 4_000 } }, + ]), + ); + + const window = await activeWindow("darwin"); + + assert.strictEqual(window?.id, 1); + assert.deepEqual(window?.bounds, bounds); +}); diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index 20f56cc19442..d8da86ff0072 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -27,23 +27,32 @@ export type ActiveWindow = { const MAC_LOOKUP_TIMEOUT_MS = 5_000; -const MacActiveWindow = Schema.Struct({ - id: Schema.Number, - title: Schema.String, - bounds: Schema.Struct({ - x: Schema.Number, - y: Schema.Number, - width: Schema.Number, - height: Schema.Number, - }), +const MAC_HELPER_STRIP_MAX_THICKNESS_RATIO = 0.25; + +const MacActiveWindowLookup = Schema.Struct({ owner: Schema.Struct({ name: Schema.String, processId: Schema.Number, path: Schema.String, bundleId: Schema.String, }), + windows: Schema.Array( + Schema.Struct({ + id: Schema.Number, + title: Schema.String, + bounds: Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + alpha: Schema.Number, + }), + ), }); -const decodeMacActiveWindow = Schema.decodeUnknownSync(Schema.fromJsonString(MacActiveWindow)); +const decodeMacActiveWindowLookup = Schema.decodeUnknownSync( + Schema.fromJsonString(MacActiveWindowLookup), +); const MAC_LOOKUP_SCRIPT = ` ObjC.import("CoreGraphics"); @@ -52,30 +61,38 @@ function run() { const app = $.NSWorkspace.sharedWorkspace.frontmostApplication; if (app.isNil()) return ""; const pid = app.processIdentifier; + let ownerName = String(app.localizedName.js || ""); const list = $.CGWindowListCopyWindowInfo( $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, $.kCGNullWindowID, ); $.CFMakeCollectable(list); const count = $.CFArrayGetCount(list); + const windows = []; for (let i = 0; i < count; i++) { const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)); if (w.objectForKey("kCGWindowOwnerPID").js !== pid) continue; if (w.objectForKey("kCGWindowLayer").js !== 0) continue; + if (!ownerName) { + ownerName = String(ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""); + } const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds")); - return JSON.stringify({ + windows.push({ id: w.objectForKey("kCGWindowNumber").js, title: String(ObjC.unwrap(w.objectForKey("kCGWindowName")) || ""), bounds: { x: b.X, y: b.Y, width: b.Width, height: b.Height }, - owner: { - name: String(app.localizedName.js || ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""), - processId: pid, - path: String(app.bundleURL.path.js || ""), - bundleId: String(app.bundleIdentifier.js || ""), - }, + alpha: w.objectForKey("kCGWindowAlpha").js, }); } - return ""; + return JSON.stringify({ + owner: { + name: ownerName, + processId: pid, + path: String(app.bundleURL.path.js || ""), + bundleId: String(app.bundleIdentifier.js || ""), + }, + windows, + }); }`; function runMacLookup(): Promise { @@ -83,7 +100,7 @@ function runMacLookup(): Promise { NodeChildProcess.execFile( "/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_LOOKUP_SCRIPT], - { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 64 * 1024 }, + { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout) => { if (error) reject(error); else resolve(stdout); @@ -95,17 +112,45 @@ function runMacLookup(): Promise { async function macActiveWindow(): Promise { const output = (await runMacLookup()).trim(); if (!output) return undefined; - const window = decodeMacActiveWindow(output); + const lookup = decodeMacActiveWindowLookup(output); + const visible = lookup.windows.filter( + (window) => window.alpha > 0 && window.bounds.width > 0 && window.bounds.height > 0, + ); + // Helpers belong to the content behind them, which may not be the app's largest window. + const window = visible.find((candidate, index) => { + if (candidate.title.trim() !== "") return true; + const bounds = candidate.bounds; + return !visible.some((reference, referenceIndex) => { + if (referenceIndex <= index) return false; + return ( + (bounds.x === reference.bounds.x && + bounds.width === reference.bounds.width && + bounds.y <= reference.bounds.y && + reference.bounds.y - bounds.y <= + reference.bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && + bounds.height <= bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && + bounds.height <= reference.bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) || + (bounds.y === reference.bounds.y && + bounds.height === reference.bounds.height && + bounds.x <= reference.bounds.x && + reference.bounds.x - bounds.x <= + reference.bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && + bounds.width <= bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && + bounds.width <= reference.bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) + ); + }); + }); + if (!window) return undefined; return { platform: "macos", id: window.id, title: window.title, bounds: window.bounds, owner: { - name: window.owner.name, - processId: window.owner.processId, - path: window.owner.path, - ...(window.owner.bundleId ? { bundleId: window.owner.bundleId } : {}), + name: lookup.owner.name, + processId: lookup.owner.processId, + path: lookup.owner.path, + ...(lookup.owner.bundleId ? { bundleId: lookup.owner.bundleId } : {}), }, }; } From ff9f636dff1cacb2d40ab4b619acde9048bb9f65 Mon Sep 17 00:00:00 2001 From: Rob Homewood Date: Sun, 1 Nov 2026 02:23:12 +0000 Subject: [PATCH 048/158] fix(provider-core): Chocolatey Claude installs stay manual-only and compare against choco (#22941) Co-authored-by: Cursor Co-authored-by: maria-rcks --- docs/internals/providers.md | 5 +- docs/user/install.md | 7 +- .../provider-core/src/server/maintenance.ts | 2 + .../src/server/maintenanceResolver.test.ts | 167 +++++++++++++++++- .../src/server/maintenanceResolver.ts | 73 +++++++- 5 files changed, 245 insertions(+), 9 deletions(-) diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 42f0a70101b3..3a31a9dac93c 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -93,7 +93,10 @@ manual-only because their version is pinned in mise's config. A Grok cask whose cannot be verified also stays manual-only: `grok update` would install a second copy. npm updates pin `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version -train, so the registry stays authoritative for them. +train, so the registry stays authoritative for them. A Chocolatey install of Claude stays +manual-only: its package directory is writable only by administrators, and `claude update` through +the shim installs a native copy elsewhere. The latest version comes from `choco search`, and the +card shows `choco upgrade claude-code` without running it. See the [resolver](../../apps/server/src/provider/providerMaintenanceRunner.ts). Ownership is cached per instance and re-read immediately before an update runs. The diff --git a/docs/user/install.md b/docs/user/install.md index 3f44f5b596b8..6f3f63330e08 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -186,9 +186,10 @@ When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** runs the installer that owns the CLI (Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the CLI's own update command when T3 Code cannot tell. Update a CLI installed with -mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs -compare against the version Homebrew offers, which can trail the npm release by -a few hours. +mise through mise. Cursor and Antigravity update with T3 Code. Homebrew and +Chocolatey installs compare against the version their package manager offers, +which can trail the npm release; update Claude installed with Chocolatey +through Chocolatey. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index 4e402717608b..86341e74ac53 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -15,6 +15,8 @@ export interface ProviderMaintenanceCapabilities { readonly compareVersions?: (current: string, latest: string) => number; /** Resolved executable path for advisories when no update command is proven. */ readonly installPath?: string; + /** Displayed for manual updates; never executed by T3 Code. */ + readonly manualCommand?: string; } export interface ProviderMaintenanceCommandAction { diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index da59b77886a5..cc20da24a33e 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -25,10 +25,12 @@ import { homebrewOwnershipFromCommandPath, invalidateLatestProviderVersion, makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, normalizeCommandPath, npmGlobalPrefixFromCommandPath, + parseChocolateyLatestVersion, parseHomebrewLatestVersion, resolveLatestProviderVersion, resolvePackageManagedProviderMaintenance, @@ -381,6 +383,26 @@ it.layer( }); }); + it("shows the Chocolatey upgrade command without offering to run it", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("claudeAgent"), + currentVersion: "2.1.304", + latestVersion: "2.1.306", + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver("claudeAgent"), + packageName: "@anthropic-ai/claude-code", + installPath: "C:\\ProgramData\\chocolatey\\bin\\claude.exe", + manualCommand: "choco upgrade claude-code", + }), + }), + ).toMatchObject({ + canUpdate: false, + canInstallVersion: false, + updateCommand: "choco upgrade claude-code", + }); + }); + it.effect("stays manual-only when the binary cannot be located", () => resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: "package-tool", @@ -547,12 +569,13 @@ it.layer( readonly resolved: string; readonly real: string; readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; }) => resolvePackageManagedProviderMaintenance(input.definition, { binaryPath: "claude", resolvedCommandPath: input.resolved, realCommandPath: input.real, - env: {}, + env: input.env ?? {}, platform: input.platform ?? "win32", }).pipe( Effect.provideService(HostProcess.Platform, input.platform ?? "win32"), @@ -585,6 +608,135 @@ it.layer( }), ); + const chocolateyShim = "C:\\ProgramData\\chocolatey\\bin\\claude.exe"; + const chocolateyPackage = "C:\\ProgramData\\chocolatey\\lib\\claude-code\\tools\\claude.exe"; + + it.effect.each([ + { name: "the shimgen shim", resolved: chocolateyShim, real: chocolateyShim }, + { name: "the package binary", resolved: chocolateyPackage, real: chocolateyPackage }, + { + name: "a custom root", + resolved: "D:\\Tools\\Choco\\bin\\claude.exe", + real: "D:\\Tools\\Choco\\bin\\claude.exe", + env: { ChocolateyInstall: "D:\\Tools\\Choco" }, + }, + { + name: "a custom root package", + resolved: "D:\\Tools\\Choco\\lib\\claude-code\\tools\\claude.exe", + real: "D:\\Tools\\Choco\\lib\\claude-code\\tools\\claude.exe", + env: { ChocolateyInstall: "D:\\Tools\\Choco" }, + }, + { + name: "a mixed-case custom root with a trailing separator", + resolved: "D:\\Tools\\Choco\\bin\\Claude.EXE", + real: "D:\\Tools\\Choco\\bin\\Claude.EXE", + env: { ChocolateyInstall: "d:/tools/choco/" }, + }, + { name: "a native-looking alias", resolved: nativeClaude, real: chocolateyPackage }, + ] as const)("keeps a Chocolatey Claude install manual-only through $name", (fixture) => + Effect.gen(function* () { + const capabilities = yield* resolveInstall({ definition: claudeUpdate, ...fixture }); + + expect(capabilities.update).toBeNull(); + expect(capabilities.installPath).toBe(fixture.real); + expect(capabilities.latestVersion).toBeNull(); + expect(capabilities.lookupLatestVersion).toBeDefined(); + expect(capabilities.manualCommand).toBe("choco upgrade claude-code"); + }), + ); + + it.effect("keeps a Chocolatey-shaped path off Windows on the native updater", () => + Effect.gen(function* () { + const commandPath = "/home/me/chocolatey/bin/claude.exe"; + const capabilities = yield* resolveInstall({ + definition: claudeUpdate, + resolved: commandPath, + real: commandPath, + platform: "linux", + }); + + expect(capabilities.update).toMatchObject({ lockKey: "claudeAgent-native" }); + }), + ); + + // Chocolatey publishes behind npm, so comparing against the registry would + // report an update `choco upgrade` cannot install yet. + it.effect("compares a Chocolatey Claude install against choco search", () => + Effect.gen(function* () { + const probes: Array<{ command: string; args: ReadonlyArray }> = []; + const capabilities = yield* resolvePackageManagedProviderMaintenance(claudeUpdate, { + binaryPath: "claude", + resolvedCommandPath: chocolateyShim, + realCommandPath: chocolateyShim, + env: {}, + platform: "win32", + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner((command, args) => { + probes.push({ command, args }); + return "claude-code|2.1.304\r\n"; + }), + ), + ); + expect(probes).toEqual([]); + + const latest = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("the installer's latest should not come from npm")), + ), + ); + + expect(latest).toBe("2.1.304"); + expect(probes.map(({ command }) => NodePath.basename(command).replace(/\.\w+$/, ""))).toEqual( + ["choco"], + ); + expect(probes[0]?.args).toEqual(["search", "claude-code", "--exact", "--limit-output"]); + expect( + createProviderVersionAdvisory({ + driver: driver("claudeAgent"), + currentVersion: "2.1.304", + latestVersion: latest, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ status: "current", canUpdate: false, canInstallVersion: false }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("stays unknown when choco search fails instead of asking npm", () => + Effect.gen(function* () { + const capabilities = yield* resolvePackageManagedProviderMaintenance(claudeUpdate, { + binaryPath: "claude", + resolvedCommandPath: chocolateyShim, + realCommandPath: chocolateyShim, + env: {}, + platform: "win32", + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner( + () => "", + () => 1, + ), + ), + ); + + const latest = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("a failed choco search should not fall back to npm")), + ), + ); + + expect(latest).toBeNull(); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([ { name: "a native install", @@ -617,6 +769,12 @@ it.layer( resolved: userWinGetLink, real: userWinGetPackage, }, + { + name: "a claude-code tools path outside Chocolatey", + definition: claudeUpdate, + resolved: "C:\\Work\\lib\\claude-code\\tools\\claude.exe", + real: "C:\\Work\\lib\\claude-code\\tools\\claude.exe", + }, { name: "another WinGet package", definition: claudeUpdate, @@ -1052,6 +1210,13 @@ it.layer( }), ); + it("reads the Chocolatey package version from choco search --limit-output", () => { + expect(parseChocolateyLatestVersion("claude-code|2.1.304\r\n", "claude-code")).toBe("2.1.304"); + expect(parseChocolateyLatestVersion("Claude-Code|2.1.304\r\n", "claude-code")).toBe("2.1.304"); + expect(parseChocolateyLatestVersion("claude-code-other|9.9.9\r\n", "claude-code")).toBeNull(); + expect(parseChocolateyLatestVersion("", "claude-code")).toBeNull(); + }); + it("recognizes Homebrew kegs and casks from the real executable path", () => { expect( homebrewOwnershipFromCommandPath("/opt/homebrew/Cellar/claude-code@latest/2.1.0/bin/claude"), diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 61ff6edc0d9a..7b1a46e1dbe4 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -199,6 +199,8 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; readonly installPath?: string; + readonly lookupLatestVersion?: Effect.Effect; + readonly manualCommand?: string; }): ProviderMaintenanceCapabilities { return { ...makeProviderMaintenanceCapabilities({ @@ -207,8 +209,12 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { updateExecutable: null, updateArgs: [], updateLockKey: null, + ...(input.lookupLatestVersion + ? { latestVersion: null, lookupLatestVersion: input.lookupLatestVersion } + : {}), }), ...(input.installPath ? { installPath: input.installPath } : {}), + ...(input.manualCommand ? { manualCommand: input.manualCommand } : {}), }; } @@ -248,6 +254,32 @@ function isWinGetClaudeCommandPath(commandPath: string): boolean { return winGetClaudeCommandPath.test(normalizeCommandPath(commandPath)); } +const CHOCOLATEY_CLAUDE_PACKAGE_ID = "claude-code"; + +// Chocolatey's executable shim is not a symlink, so realpath cannot prove its owner. +const chocolateyClaudeCommandPath = /\/chocolatey\/(?:bin|lib\/claude-code\/tools)\/claude\.exe$/; + +function isChocolateyClaudeCommandPath(commandPath: string, env: NodeJS.ProcessEnv): boolean { + const normalized = normalizeCommandPath(commandPath); + if (chocolateyClaudeCommandPath.test(normalized)) return true; + const root = nonEmptyString(env.ChocolateyInstall); + if (!root) return false; + const prefix = normalizeCommandPath(root.replace(/[\\/]+$/, "")); + return ( + normalized === `${prefix}/bin/claude.exe` || + normalized === `${prefix}/lib/claude-code/tools/claude.exe` + ); +} + +/** `choco search --exact --limit-output` prints `|` for a match and nothing otherwise. */ +export function parseChocolateyLatestVersion(output: string, packageId: string): string | null { + for (const line of output.split(/\r?\n/)) { + const [id, version] = line.trim().split("|", 2); + if (id?.toLowerCase() === packageId.toLowerCase()) return nonEmptyString(version); + } + return null; +} + function isPnpmGlobalCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -447,6 +479,32 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( ) { return manual; } + // Chocolatey's package directory is writable only by administrators, so an + // unelevated server cannot run `choco upgrade`; `claude update` through the + // shim installs a native copy elsewhere and leaves the shim's version alone. + if ( + definition.provider === "claudeAgent" && + context.platform === "win32" && + commandPaths.some((commandPath) => isChocolateyClaudeCommandPath(commandPath, context.env)) + ) { + const services = yield* Effect.context(); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName, + installPath: context.realCommandPath, + manualCommand: "choco upgrade claude-code", + lookupLatestVersion: runProbe( + "choco", + ["search", CHOCOLATEY_CLAUDE_PACKAGE_ID, "--exact", "--limit-output"], + context.env, + ).pipe( + Effect.map((output) => + output ? parseChocolateyLatestVersion(output, CHOCOLATEY_CLAUDE_PACKAGE_ID) : null, + ), + Effect.provideContext(services), + ), + }); + } const nativeUpdate = definition.nativeUpdate; const nativeEnv = nativeUpdate?.env; @@ -872,7 +930,10 @@ export function createProviderVersionAdvisory(input: { status: advisory.status, currentVersion: input.currentVersion, latestVersion, - updateCommand: resolveLatestProviderUpdateAction(capabilities, latestVersion)?.command ?? null, + updateCommand: + resolveLatestProviderUpdateAction(capabilities, latestVersion)?.command ?? + capabilities.manualCommand ?? + null, canUpdate: capabilities.update !== null, canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, checkedAt: input.checkedAt ?? null, @@ -895,12 +956,16 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack return Option.isSome(payload) ? nonEmptyString(payload.value.version) : null; }); -/** The installer's own answer is cached apart from the npm registry entry. */ +/** + * The installer's own answer is cached apart from the npm registry entry. A + * manual-only install has no command to key on, so its executable stands in. + */ function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) { if (!capabilities.lookupLatestVersion) return null; const update = capabilities.update; - return update - ? [update.lockKey, update.executable, ...update.args].join("\0") + if (update) return [update.lockKey, update.executable, ...update.args].join("\0"); + return capabilities.installPath + ? ["manual", capabilities.installPath].join("\0") : capabilities.packageName && `npm-registry\0${capabilities.packageName}`; } From 85133dc57818cbd971d3adf91f83980a83e65ec6 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Sat, 31 Oct 2026 22:23:27 -0400 Subject: [PATCH 049/158] fix(web): PR diffs list a `.logic.test.ts` file beside the source it covers (#23129) Co-authored-by: Claude Opus 5.5 --- .../pullRequestFileOrder.logic.test.ts | 20 +++++++++++++++++++ .../pullRequest/pullRequestFileOrder.logic.ts | 20 ++++++++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index a6e20a6c8c83..899aff44f193 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -118,4 +118,24 @@ describe("orderDiffFiles", () => { "src/orphan.spec.ts", ]); }); + + it("pairs a test with the source whose name carries its own dotted suffix", () => { + expect( + order([ + file("src/c.browser.test.tsx"), + file("src/b.test.ts"), + file("src/a.logic.test.ts"), + file("src/c.tsx"), + file("src/b.ts"), + file("src/a.logic.ts"), + ]), + ).toEqual([ + "src/a.logic.ts", + "src/b.ts", + "src/c.tsx", + "src/a.logic.test.ts", + "src/b.test.ts", + "src/c.browser.test.tsx", + ]); + }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts index 738b372585a6..737206d96e50 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts @@ -117,8 +117,17 @@ function orderByImports( return ordered; } -function testedBaseName(path: string): string { - return (path.split("/").at(-1) ?? "").replace(/\.(?:test|spec)\..*$/, "").replace(/\.[^.]+$/, ""); +// `a.logic.test.ts` covers `a.logic.ts` while `a.browser.test.ts` covers `a.ts`, so the longest +// dotted prefix that names a source file wins. +function testedSourcePosition(path: string, sourcePositions: ReadonlyMap): number { + let name = (path.split("/").at(-1) ?? "").replace(/\.(?:test|spec)\..*$/, ""); + while (true) { + const position = sourcePositions.get(name); + if (position !== undefined) return position; + const dot = name.lastIndexOf("."); + if (dot <= 0) return Number.MAX_SAFE_INTEGER; + name = name.slice(0, dot); + } } export function orderDiffFiles( @@ -159,9 +168,10 @@ export function orderDiffFiles( const orderedTests = [...byPath.keys()] .filter((path) => tiers.get(path) === "test") .sort((left, right) => { - const leftPosition = sourcePositions.get(testedBaseName(left)) ?? Number.MAX_SAFE_INTEGER; - const rightPosition = sourcePositions.get(testedBaseName(right)) ?? Number.MAX_SAFE_INTEGER; - return leftPosition - rightPosition || left.localeCompare(right); + return ( + testedSourcePosition(left, sourcePositions) - + testedSourcePosition(right, sourcePositions) || left.localeCompare(right) + ); }); const orderedGenerated = [...byPath.keys()] From 651e390ad2629ebd642cab40c58787f6246b27b5 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:23:42 -0500 Subject: [PATCH 050/158] perf(desktop): speed up Hyprland and KDE SnapShots (#23137) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- native/hyprland-snap-shot/src/capture.rs | 53 +++++++++++++++--- .../hyprland-snap-shot/src/transport_tests.rs | 4 ++ native/kde-snap-shot/src/main.rs | 54 ++++++++++++++++--- native/kde-snap-shot/src/transport_tests.rs | 4 ++ 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/native/hyprland-snap-shot/src/capture.rs b/native/hyprland-snap-shot/src/capture.rs index 9e8e74bed675..5519b4249e9d 100644 --- a/native/hyprland-snap-shot/src/capture.rs +++ b/native/hyprland-snap-shot/src/capture.rs @@ -25,7 +25,7 @@ use smithay_client_toolkit::{ }; use std::{ fs::OpenOptions, - io::BufWriter, + io::Write, os::unix::fs::OpenOptionsExt, path::Path, time::{Duration, Instant}, @@ -140,18 +140,36 @@ pub(super) fn capture_on(connection: Connection, address: u64, directory: &Path) pixels.format, state.inverted, )?; - let file = OpenOptions::new() + let png = encode_png(&rgba, pixels.width, pixels.height, MAX_PNG_BYTES)?; + OpenOptions::new() .write(true) .create_new(true) .mode(0o600) - .open(directory.join("capture.png"))?; - let mut encoder = png::Encoder::new(BufWriter::new(file), pixels.width, pixels.height); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder.write_header()?.write_image_data(&rgba)?; + .open(directory.join("capture.png"))? + .write_all(&png)?; Ok(()) } +/// The desktop app rejects larger capture PNGs (`linuxCaptureSession.ts`). +const MAX_PNG_BYTES: usize = 32 * 1024 * 1024; + +/// Level 6 dominated capture time, so encode at the much faster level 2 and fall back +/// to level 6 only when level 2's slightly larger output would be rejected. +fn encode_png(rgba: &[u8], width: u32, height: u32, limit: usize) -> Result> { + for level in [2, 6] { + let mut png = Vec::new(); + let mut encoder = png::Encoder::new(&mut png, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_deflate_compression(png::DeflateCompression::Level(level)); + encoder.write_header()?.write_image_data(rgba)?; + if png.len() <= limit { + return Ok(png); + } + } + Err("Capture is too large.".into()) +} + fn image_size(width: u32, height: u32, stride: u32) -> Result { if width == 0 || height == 0 || width > 16384 || height > 16384 || stride < width * 4 { return Err("Invalid capture dimensions.".into()); @@ -331,4 +349,25 @@ mod tests { ); assert!(to_rgba(&bytes[..2], 1, 1, 4, wl_shm::Format::Argb8888, false).is_err()); } + #[test] + fn falls_back_to_smaller_png_when_fast_encode_exceeds_limit() { + let (width, height) = (256, 256); + let rgba: Vec = (0..width * height * 4) + .map(|i| (((i / 4) % 251) ^ (i % 7)) as u8) + .collect(); + let decode = |png: &[u8]| { + let mut reader = png::Decoder::new(std::io::Cursor::new(png)) + .read_info() + .unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + reader.next_frame(&mut pixels).unwrap(); + pixels + }; + let fast = encode_png(&rgba, width, height, usize::MAX).unwrap(); + let fallback = encode_png(&rgba, width, height, fast.len() - 1).unwrap(); + assert!(fallback.len() < fast.len()); + assert_eq!(decode(&fast), rgba); + assert_eq!(decode(&fallback), rgba); + assert!(encode_png(&rgba, width, height, fallback.len() - 1).is_err()); + } } diff --git a/native/hyprland-snap-shot/src/transport_tests.rs b/native/hyprland-snap-shot/src/transport_tests.rs index e215cabcfe30..2f9da9764d9b 100644 --- a/native/hyprland-snap-shot/src/transport_tests.rs +++ b/native/hyprland-snap-shot/src/transport_tests.rs @@ -363,6 +363,10 @@ fn exports_exact_window_over_real_fd_transport() { let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut pixels).unwrap(); assert_eq!(pixels, [10, 20, 30, 255, 40, 50, 60, 255]); + let png = std::fs::read(directory.path().join("capture.png")).unwrap(); + let idat = png.windows(4).position(|tag| tag == b"IDAT").unwrap(); + // zlib FLEVEL 1 selects levels 2-5; the slow default level 6 reports 2. + assert_eq!(png[idat + 5] >> 6, 1); } #[test] fn failed_export_produces_no_image_and_no_second_capture() { diff --git a/native/kde-snap-shot/src/main.rs b/native/kde-snap-shot/src/main.rs index 49b85a3bb499..ec1256779397 100644 --- a/native/kde-snap-shot/src/main.rs +++ b/native/kde-snap-shot/src/main.rs @@ -261,6 +261,26 @@ fn rgba(raw: &[u8], width: usize, height: usize, stride: usize, format: usize) - Ok(output) } +/// The desktop app rejects larger capture PNGs (`linuxCaptureSession.ts`). +const MAX_PNG_BYTES: usize = 32 * 1024 * 1024; + +/// Level 6 dominated capture time, so encode at the much faster level 2 and fall back +/// to level 6 only when level 2's slightly larger output would be rejected. +fn encode_png(rgba: &[u8], width: u32, height: u32, limit: usize) -> Result> { + for level in [2, 6] { + let mut png = Vec::new(); + let mut encoder = png::Encoder::new(&mut png, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_deflate_compression(png::DeflateCompression::Level(level)); + encoder.write_header()?.write_image_data(rgba)?; + if png.len() <= limit { + return Ok(png); + } + } + Err("Screenshot too large".into()) +} + fn capture(connection: &Connection, directory: &Path) -> Result> { // Lack of scripting only loses identity/text; never guess which app was captured. let before = window(connection, directory, None).ok().flatten(); @@ -299,16 +319,12 @@ fn capture(connection: &Connection, directory: &Path) -> Result> let mut raw = vec![0; bytes]; reader.read_exact(&mut raw)?; let pixels = rgba(&raw, width, height, stride, uint(&metadata, "format")?)?; - let image = OpenOptions::new() + let png = encode_png(&pixels, width as u32, height as u32, MAX_PNG_BYTES)?; + OpenOptions::new() .write(true) .create_new(true) - .open(directory.join("capture.png"))?; - let mut encoder = png::Encoder::new(image, width as u32, height as u32); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let mut image = encoder.write_header()?; - image.write_image_data(&pixels)?; - image.finish()?; + .open(directory.join("capture.png"))? + .write_all(&png)?; let identity = before.filter(|before| { window(connection, directory, Some(&before.id)) .ok() @@ -453,6 +469,28 @@ mod tests { } } + #[test] + fn falls_back_to_smaller_png_when_fast_encode_exceeds_limit() { + let (width, height) = (256, 256); + let rgba: Vec = (0..width * height * 4) + .map(|i| (((i / 4) % 251) ^ (i % 7)) as u8) + .collect(); + let decode = |png: &[u8]| { + let mut reader = png::Decoder::new(std::io::Cursor::new(png)) + .read_info() + .unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + reader.next_frame(&mut pixels).unwrap(); + pixels + }; + let fast = encode_png(&rgba, width, height, usize::MAX).unwrap(); + let fallback = encode_png(&rgba, width, height, fast.len() - 1).unwrap(); + assert!(fallback.len() < fast.len()); + assert_eq!(decode(&fast), rgba); + assert_eq!(decode(&fallback), rgba); + assert!(encode_png(&rgba, width, height, fallback.len() - 1).is_err()); + } + #[test] fn decodes_native_argb_and_row_padding() { let raw = [0xff112233_u32.to_ne_bytes(), [0; 4]].concat(); diff --git a/native/kde-snap-shot/src/transport_tests.rs b/native/kde-snap-shot/src/transport_tests.rs index efc737a1cd54..83e72556c0d1 100644 --- a/native/kde-snap-shot/src/transport_tests.rs +++ b/native/kde-snap-shot/src/transport_tests.rs @@ -247,6 +247,10 @@ fn captures_pinned_window_through_real_fd_and_cleans_scripts() { let mut pixels = vec![0; decoder.output_buffer_size().unwrap()]; decoder.next_frame(&mut pixels).unwrap(); assert_eq!(pixels, [0x11, 0x22, 0x33, 255, 0x44, 0x55, 0x66, 255]); + let png = std::fs::read(f.directory.join("capture.png")).unwrap(); + let idat = png.windows(4).position(|tag| tag == b"IDAT").unwrap(); + // zlib FLEVEL 1 selects levels 2-5; the slow default level 6 reports 2. + assert_eq!(png[idat + 5] >> 6, 1); } #[test] From 6d18edd9635d653191ebce58b1e86cb02023f13d Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Sat, 31 Oct 2026 22:23:57 -0400 Subject: [PATCH 051/158] fix(mobile): Bitbucket and GitCafe approvals show as approved on the PR timeline (#23138) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../pull-requests/PullRequestTimeline.tsx | 6 ++++-- .../pull-requests/pullRequestStatus.test.ts | 18 +++++++++++++++++- .../pull-requests/pullRequestStatus.ts | 9 +++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx index 424f8cdff1b3..8f4b1e45a869 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx @@ -14,6 +14,7 @@ import { PullRequestAvatar } from "./PullRequestPresentation"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestCommentFooter } from "./PullRequestReactions"; import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; +import { normalizedReviewState } from "./pullRequestStatus"; import type { PullRequestParams } from "./usePullRequestDiff"; function TimelineEvent(props: { @@ -80,8 +81,9 @@ function TimelineComment({ [comment.body], ); const preview = content.preview; - const approved = comment.reviewState === "APPROVED"; - const changes = comment.reviewState === "CHANGES_REQUESTED"; + const state = normalizedReviewState(comment.reviewState); + const approved = state === "APPROVED"; + const changes = state === "CHANGES_REQUESTED"; return ( { }); }); +describe("mobile review state spelling", () => { + it("reads GitHub's uppercase and Bitbucket's or GitCafe's lowercase verdicts alike", () => { + expect(normalizedReviewState("APPROVED")).toBe("APPROVED"); + expect(normalizedReviewState("approved")).toBe("APPROVED"); + expect(normalizedReviewState("CHANGES_REQUESTED")).toBe("CHANGES_REQUESTED"); + expect(normalizedReviewState("changes_requested")).toBe("CHANGES_REQUESTED"); + expect(normalizedReviewState("changes-requested")).toBe("CHANGES_REQUESTED"); + expect(normalizedReviewState("commented")).toBe("COMMENTED"); + expect(normalizedReviewState(null)).toBeUndefined(); + }); +}); + describe("mobile overview check status", () => { it("prioritizes failed checks while others are running", () => { expect( diff --git a/apps/mobile/src/features/pull-requests/pullRequestStatus.ts b/apps/mobile/src/features/pull-requests/pullRequestStatus.ts index fa802a430959..5426266dc689 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestStatus.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestStatus.ts @@ -135,6 +135,11 @@ export function pullRequestChecksSummary(pr: Pick) } as const; } +// GitHub and Forgejo send uppercase review states; Bitbucket and GitCafe send lowercase. +export function normalizedReviewState(reviewState: string | null) { + return reviewState?.toUpperCase().replaceAll("-", "_"); +} + export function pullRequestReviewSummary( pr: Pick, activity: Pick | null, @@ -142,8 +147,8 @@ export function pullRequestReviewSummary( const latest = new Map(); const comments = new Map(); for (const review of activity?.comments ?? []) { - if (review.kind !== "review" || !review.reviewState) continue; - const state = review.reviewState.toUpperCase().replaceAll("-", "_"); + const state = normalizedReviewState(review.reviewState); + if (review.kind !== "review" || !state) continue; const key = review.author?.login ?? review.id; if (state === "COMMENTED") { const previous = comments.get(key); From 207faa28c645b1d1a63205e080c2ac2c98d7b31b Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Sat, 31 Oct 2026 22:24:13 -0400 Subject: [PATCH 052/158] fix(server): terminal labels keep the whole name of a command with spaces (#23140) Co-authored-by: Claude Opus 5.5 --- apps/server/src/terminal/Manager.test.ts | 24 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 5 ++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 8e1a2d2f7ec3..2b08e4f51e5a 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -765,6 +765,30 @@ it.layer( }), ); + it.effect("labels a running command by its whole process name, spaces included", () => + Effect.gen(function* () { + const { manager } = yield* createManager(5, { + subprocessPollIntervalMs: 1_000, + processKillGraceMs: 0, + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9000, name: "Google Chrome" }, + ]), + }).pipe(Effect.provide(layerWithHostPlatform("darwin"))); + const label = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "activity" + ? Deferred.succeed(label, event.label).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + + expect(yield* Deferred.await(label)).toBe("Google Chrome"); + }).pipe(Effect.provide(TestClock.layer())), + ); + // Answers process-table queries after `queryDuration`, honoring the caller's // timeout the way ProcessRunner's `timedOutResult` mode does. const slowProcessTable = ( diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9d3942c6cb62..c881dbd7b19a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -365,10 +365,9 @@ function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): stri ) { trimmed = trimmed.slice(1, -1).trim(); } - const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); - if (firstToken.length === 0) return null; + // Process names and macOS `comm` paths can contain spaces ("Google Chrome"). const separators = platform === "win32" ? /[\\/]/ : /\//; - const base = firstToken.split(separators).at(-1) ?? firstToken; + const base = (trimmed.split(separators).at(-1) ?? trimmed).trim(); const withoutExe = platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; return withoutExe.length > 0 ? withoutExe : null; From 549b651bf96f74ef63c1d9c19302d5068d006b9b Mon Sep 17 00:00:00 2001 From: RVZO6 <66844656+RVZO6@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:24:30 +0000 Subject: [PATCH 053/158] fix(mobile): disabled send button is readable in Material You dark (#23155) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/mobile/src/components/ComposerToolbar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index 75e167fae66c..cabaabd64a24 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -274,7 +274,7 @@ export function ComposerActionButton(props: { props.variant === "danger" ? "bg-danger" : props.disabled - ? "bg-primary/15" + ? "bg-foreground/12" : "bg-primary", )} > @@ -283,7 +283,11 @@ export function ComposerActionButton(props: { size={props.size === "large" ? Math.round(20 * scale) : smallIconSize} weight="semibold" tintColorClassName={ - props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground" + props.variant === "danger" + ? "accent-danger-foreground" + : props.disabled + ? "accent-foreground/38" + : "accent-primary-foreground" } type="monochrome" /> From 3331b9393755ca6fe0e7880f953ae0f36ec0f627 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:24:45 -0500 Subject: [PATCH 054/158] fix(web): show linked pull request state in command palette rows (#23156) Co-authored-by: GPT-6.1 Sol --- ...StatusIndicators.linkedPr.browser.test.tsx | 66 +++++++++++++++++++ .../src/components/ThreadStatusIndicators.tsx | 9 ++- 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx diff --git a/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx b/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx new file mode 100644 index 000000000000..2f690d88f5b9 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx @@ -0,0 +1,66 @@ +import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { makeThreadFixture } from "../test-fixtures"; +import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; + +const environmentId = EnvironmentId.make("environment-test"); + +vi.mock(import("../state/entities"), async (original) => ({ + ...(await original()), + useProject: () => null, + useServerConfigs: (() => + new Map([ + [environmentId, { environment: { capabilities: { threadPullRequests: true } } }], + ])) as never, +})); + +it("updates palette PR status from linked snapshots without a legacy link or branch PR", async () => { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "example/repo", + number: 7, + url: "https://github.com/example/repo/pull/7", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: { + state: "merged", + title: "Linked change", + headBranch: "deleted-branch", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }; + const row = (pullRequest: ThreadPullRequestLink) => ( + + + + ); + await using screen = await render(row(link)); + await expect.element(screen.getByLabelText("PR #7 - Merged: Linked change")).toBeVisible(); + await expect.element(screen.getByLabelText("PR #7, status pending")).not.toBeInTheDocument(); + + await screen.rerender(row({ ...link, snapshot: null })); + await expect.element(screen.getByLabelText("PR #7, status pending")).toBeVisible(); + await expect + .element(screen.getByLabelText("PR #7 - Merged: Linked change")) + .not.toBeInTheDocument(); + + await screen.rerender( + row({ ...link, snapshot: { ...link.snapshot!, state: "open", isDraft: true } }), + ); + await expect.element(screen.getByLabelText("PR #7 - Draft: Linked change")).toBeVisible(); + await expect.element(screen.getByLabelText("PR #7, status pending")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0af0e063bdea..ea49b01665b6 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -674,6 +674,8 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar thread.environmentId, thread.linkedPullRequest, leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && @@ -696,8 +698,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar linkedPullRequest: thread.linkedPullRequest, linkedPullRequestStatus: linkedPullRequest, }; - const pr = resolveDisplayedThreadPr(displayedPrInput); - const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput)); + const pr = linkedPullRequest?.pr ?? resolveDisplayedThreadPr(displayedPrInput); + const prStatus = prStatusIndicator( + pr, + linkedPullRequest?.sourceControlProvider ?? resolveDisplayedThreadPrProvider(displayedPrInput), + ); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, From 9d2634e9bdbe7d8708e6644b16ba333eb02ed3a2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:25:00 -0500 Subject: [PATCH 055/158] fix(desktop): read the Chrome cookie key off the main thread on macOS (#23157) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../BrowserImport/ChromiumKeys.test.ts | 23 +++++++++++++++---- .../src/preview/BrowserImport/ChromiumKeys.ts | 6 ++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts index 69f21285e196..6571400efbfe 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -19,10 +19,17 @@ import { import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; import { BROWSER_IMPORT_SOURCES } from "./Sources.ts"; -const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() })); +const { getPassword } = vi.hoisted(() => ({ + getPassword: vi.fn<() => Promise>(), +})); vi.mock("@napi-rs/keyring", () => ({ Entry: class { + getPassword() { + throw new Error("synchronous keychain read"); + } + }, + AsyncEntry: class { getPassword = getPassword; }, })); @@ -91,18 +98,24 @@ describe("macOS Chromium secrets", () => { it.effect("derives the cookie key from the keychain secret", () => Effect.gen(function* () { - getPassword.mockReturnValue("macos-secret"); + getPassword.mockResolvedValue("macos-secret"); const keys = yield* resolveChromiumKeys(request); expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74"); }).pipe(Effect.provide(layerNoProcesses)), ); + it.effect("reports a keychain entry the async read resolves as undefined", () => + Effect.gen(function* () { + getPassword.mockResolvedValue(undefined); + const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); + expect(error.reason).toBe("keychainItemMissing"); + }).pipe(Effect.provide(layerNoProcesses)), + ); + it.effect("preserves a denied keychain approval", () => Effect.gen(function* () { const denied = new Error("User denied access"); - getPassword.mockImplementation(() => { - throw denied; - }); + getPassword.mockRejectedValue(denied); const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); expect(error.reason).toBe("needsKeychainApproval"); expect(error.cause).toBe(denied); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index bc9edcfe52a8..962c3680d6aa 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -59,8 +59,8 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function try: () => import("@napi-rs/keyring"), catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), }); - const secret = yield* Effect.try({ - try: () => new Keyring.Entry(service, account).getPassword(), + const secret = yield* Effect.tryPromise({ + try: () => new Keyring.AsyncEntry(service, account).getPassword(), catch: (cause) => { const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); const missing = /no (matching )?entry|not found/i.test(message); @@ -70,7 +70,7 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function }); }, }); - if (secret === null || secret === "") { + if (!secret) { return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); } return secret; From 6262c9a2ba51fec145bf3efd35126ce72739a8be Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:25:16 -0500 Subject: [PATCH 056/158] fix(screenshots): reject corrupt PNGs during validation (#23161) Co-authored-by: GPT-6.1 Sol --- scripts/mobile-showcase.test.ts | 51 +++++++++++++++++++++++++++++++++ scripts/mobile-showcase.ts | 5 ++++ 2 files changed, 56 insertions(+) diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index d862678ff6ed..7e6c9e0f0e6d 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -2,11 +2,13 @@ import { assert, it } from "@effect/vitest"; import { PNG } from "pngjs"; +import type { ShowcaseStoreAssetSpec } from "./mobile-showcase.config.ts"; import { encodeAndroidPairingUrls, normalizeStorePng, parsePairingCredentialOutput, readPngMetadata, + validateStoreAsset, } from "./mobile-showcase.ts"; function rgbaPng(width: number, height: number): Buffer { @@ -26,6 +28,55 @@ it("converts simulator RGBA captures to upload-safe 24-bit RGB PNGs", () => { }); }); +const storeSpec: ShowcaseStoreAssetSpec = { + store: "apple", + directory: "fixture", + width: 2, + height: 3, + minimumUploadCount: 1, + maximumUploadCount: 10, +}; + +it("validates a complete RGB PNG from an offset byte view", () => { + const bytes = normalizeStorePng(rgbaPng(2, 3)); + const padded = Buffer.concat([Buffer.from([0]), bytes, Buffer.from([0])]); + assert.deepStrictEqual(validateStoreAsset(storeSpec, padded.subarray(1, -1)), { + width: 2, + height: 3, + bitDepth: 8, + colorType: 2, + hasAlpha: false, + }); +}); + +it("rejects PNG metadata without image data during store validation", () => { + const bytes = normalizeStorePng(rgbaPng(2, 3)); + assert.throws( + () => validateStoreAsset(storeSpec, bytes.subarray(0, 26), "threads.png"), + /threads\.png is not a valid PNG/, + ); + assert.throws(() => validateStoreAsset(storeSpec, bytes.subarray(0, 33)), /is not a valid PNG/); +}); + +it("rejects a PNG truncated inside its image data", () => { + const bytes = normalizeStorePng(rgbaPng(2, 3)); + const imageData = bytes.indexOf("IDAT"); + assert.notEqual(imageData, -1); + assert.throws( + () => validateStoreAsset(storeSpec, bytes.subarray(0, imageData + 5)), + /is not a valid PNG/, + ); +}); + +it("rejects a PNG with a corrupt image-data checksum", () => { + const bytes = normalizeStorePng(rgbaPng(2, 3)); + const imageData = bytes.indexOf("IDAT"); + assert.notEqual(imageData, -1); + const checksum = imageData + 4 + bytes.readUInt32BE(imageData - 4); + bytes[checksum] = bytes.readUInt8(checksum) ^ 1; + assert.throws(() => validateStoreAsset(storeSpec, bytes), /is not a valid PNG/); +}); + it("reads multiline JSON from the pairing CLI", () => { assert.equal( parsePairingCredentialOutput('server log\n{\n "credential": "PAIR-ME"\n}\n'), diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 17a609b98947..425c371fd0c2 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -206,6 +206,11 @@ export function validateStoreAsset( throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`); } } + try { + PNG.sync.read(Buffer.from(bytes)); + } catch (cause) { + throw new Error(`${label} is not a valid PNG.`, { cause }); + } return metadata; } From bc799c4ba55a85b855083da53d3674153e40a08b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:25:31 -0500 Subject: [PATCH 057/158] fix(web): give non-Latin theme names distinct stable ids (#23162) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- apps/web/src/themePalette.test.ts | 11 +++++++++++ apps/web/src/themePalette.ts | 15 +++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index f6903655449b..5069cf4f8187 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -61,6 +61,17 @@ function contrastRatio(first: string, second: string): number { } describe("theme files", () => { + it("gives non-Latin theme names distinct stable ids", () => { + const file = { + version: THEME_FILE_VERSION, + appearance: "dark", + colors: { canvas: "#101820" }, + }; + const night = parseThemeFile({ ...file, name: "夜" }); + expect(parseThemeFile({ ...file, name: "朝" }).id).not.toBe(night.id); + expect(parseThemeFile({ ...file, name: " 夜 " }).id).toBe(night.id); + }); + it("keeps built-in and standard search pairs readable and distinct from inline code", () => { const toOklab = converter("oklab"); const distance = (first: string, second: string) => { diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index e7d9c5d5bf2f..3c561e5417ea 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1220,13 +1220,20 @@ export function getThemePreferenceMode(theme: ThemePreference): ThemeAppearance } export function themeIdFromName(name: string): string { - const normalized = name - .trim() - .toLowerCase() + const label = name.trim().toLowerCase(); + const normalized = label .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); - return normalized || "custom-theme"; + if (normalized || !label) return normalized || "custom-theme"; + + // Names without ASCII letters or digits still need independent, stable ids. + let hash = 2_166_136_261; + for (let index = 0; index < label.length; index += 1) { + hash ^= label.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return `theme-${(hash >>> 0).toString(36)}`; } export class ThemeLibraryStorageError extends Schema.TaggedError()( From 0671305178330c8b38dff47b34f4acdc688a5c42 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:25:46 -0500 Subject: [PATCH 058/158] fix(server): generated commit messages use source patches (#23164) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index a89863d4f7f4..404842ada91e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4810,6 +4810,29 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("commit context", () => { + it.effect("prepares source patches without running textconv", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "diff.probe.textconv", "touch textconv-ran; echo converted"]); + yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); + yield* writeTextFile(cwd, "notes.doc", "original source\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "source document"]); + yield* writeTextFile(cwd, "notes.doc", "updated source\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const context = yield* driver.prepareCommitContext(cwd, ["notes.doc"]); + + assert.include(context?.stagedPatch ?? "", "-original source"); + assert.include(context?.stagedPatch ?? "", "+updated source"); + assert.notInclude(context?.stagedPatch ?? "", "converted"); + assert.include(context?.stagedSummary ?? "", "notes.doc"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); + }), + ); + it.effect("can omit the patch while preserving change detection and the real index", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); From f9dd7216c0f81904adde0ce6fc6d338ea0342fd8 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:26:01 -0500 Subject: [PATCH 059/158] fix(web): keep newer titles when an untouched header rename ends (#23171) Co-authored-by: GPT-6.1 Sol --- .../chat/ChatHeader.browser.test.tsx | 73 +++++++++++++++++++ apps/web/src/components/chat/ChatHeader.tsx | 5 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ChatHeader.browser.test.tsx diff --git a/apps/web/src/components/chat/ChatHeader.browser.test.tsx b/apps/web/src/components/chat/ChatHeader.browser.test.tsx new file mode 100644 index 000000000000..115de39efe25 --- /dev/null +++ b/apps/web/src/components/chat/ChatHeader.browser.test.tsx @@ -0,0 +1,73 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +const persisted = vi.hoisted(() => ({ title: "" })); +vi.mock(import("../../state/session"), async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ + ...(await importOriginal()), + useOrchestrationCommand: (() => + async ({ input }: { input: { title: string } }) => { + persisted.title = input.title; + return { _tag: "Success" }; + }) as never, +})); +vi.mock(import("~/hooks/useThreadActionMenu"), () => ({ + useThreadActionMenu: () => ({ openMenu: () => {}, closeMenu: () => {} }), +})); + +import { render } from "../../../test/render"; +import { ChatHeader } from "./ChatHeader"; + +function header(title: string) { + return ( + <> + {}} + rightPanelOpen={false} + onNewThreadInProject={() => {}} + /> +
Outside rename
+ + ); +} + +it.each(["blur", "Enter"] as const)( + "preserves a concurrent title update when an untouched rename ends with %s", + async (finish) => { + persisted.title = "New thread"; + await using screen = await render(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for New thread" }).dblClick(); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await expect.element(input).toHaveValue("New thread"); + + persisted.title = "Fix login redirect"; + await screen.rerender(header(persisted.title)); + if (finish === "Enter") await userEvent.keyboard("{Enter}"); + else await screen.getByText("Outside rename").click(); + + await expect.element(input).not.toBeInTheDocument(); + expect(persisted.title).toBe("Fix login redirect"); + + await screen.rerender(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for Fix login redirect" }).dblClick(); + await screen.getByRole("textbox", { name: "Thread title" }).fill("My edited title"); + persisted.title = "Another concurrent title"; + await screen.rerender(header(persisted.title)); + await userEvent.keyboard("{Enter}"); + await expect + .element(screen.getByRole("textbox", { name: "Thread title" })) + .not.toBeInTheDocument(); + expect(persisted.title).toBe("My edited title"); + }, +); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 144402d1dfe9..49f21a1ddc82 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -175,7 +175,10 @@ export const ChatHeader = memo(function ChatHeader({ const commitRename = (title: string) => { setRenaming(null); if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return; - const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + const resolution = resolveRenameCommit({ + title, + originalTitle: renamingTitle ?? activeThreadTitle, + }); if (resolution.action === "reject-empty") { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; From e8b877c5479dc4ca6fc1fbb0b123b8f4b490fb7f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:26:16 -0500 Subject: [PATCH 060/158] fix(observability): record OTLP/JSON spans that leave out empty fields (#23177) Co-authored-by: Claude Opus 5.5 --- packages/shared/src/observability.test.ts | 52 +++++++++++++++++++++++ packages/shared/src/observability.ts | 47 ++++++++++---------- 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 4a1119ef0b3d..79d6b9ec9a55 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -6,6 +6,7 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import { cast } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; @@ -13,10 +14,12 @@ import * as Ref from "effect/Ref"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; +import type { OtlpTracer } from "effect/observability"; import { causeErrorTag, compactTraceAttributes, + decodeOtlpTraceRecords, errorTag, makeLocalFileTracer, makeTraceSink, @@ -557,6 +560,55 @@ describe("observability", () => { }); }); +describe("decodeOtlpTraceRecords", () => { + it("decodes spans when OTLP/JSON omits unset and empty fields", () => { + // Protobuf JSON leaves out unset and empty fields, so this batch is valid. + const payload = cast({ + resourceSpans: [ + {}, + { + scopeSpans: [ + {}, + { + spans: [ + { + traceId: "trace-1", + spanId: "span-1", + name: "browser.request", + startTimeUnixNano: "1000000", + endTimeUnixNano: "3000000", + attributes: [{ key: "tags", value: { arrayValue: {} } }], + }, + ], + }, + ], + }, + ], + }); + + assert.deepStrictEqual(decodeOtlpTraceRecords(cast({})), []); + assert.deepStrictEqual(decodeOtlpTraceRecords(payload), [ + { + type: "otlp-span", + name: "browser.request", + traceId: "trace-1", + spanId: "span-1", + sampled: true, + kind: "internal", + startTimeUnixNano: "1000000", + endTimeUnixNano: "3000000", + durationMs: 2, + attributes: { tags: [] }, + resourceAttributes: {}, + scope: { attributes: {} }, + events: [], + links: [], + status: { code: "0" }, + }, + ]); + }); +}); + describe("OtlpHeadersFromString", () => { const decode = Schema.decodeUnknownSync(OtlpHeadersFromString); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index ec2eeb544e6a..b32edb17015c 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -185,6 +185,7 @@ type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number]; type OtlpSpanEvent = OtlpSpan["events"][number]; type OtlpSpanLink = OtlpSpan["links"][number]; type OtlpSpanStatus = OtlpSpan["status"]; +type OtlpScope = OtlpTracer.ScopeSpan["scope"]; interface SerializableSpan { readonly name: string; @@ -686,24 +687,22 @@ export function decodeOtlpTraceRecords( ): ReadonlyArray { const records: Array = []; - for (const resourceSpan of payload.resourceSpans) { - const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []); + // OTLP/JSON follows protobuf JSON, which omits unset and empty fields, so a + // valid batch can leave out any field read here. + for (const resourceSpan of payload.resourceSpans ?? []) { + const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes); - for (const scopeSpan of resourceSpan.scopeSpans) { - for (const span of scopeSpan.spans) { + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + const scope: Partial = scopeSpan.scope ?? {}; + for (const span of scopeSpan.spans ?? []) { records.push( otlpSpanToTraceRecord({ resourceAttributes, scopeAttributes: decodeAttributes( - "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes) - ? scopeSpan.scope.attributes - : [], + "attributes" in scope && Array.isArray(scope.attributes) ? scope.attributes : [], ), - scopeName: scopeSpan.scope.name, - scopeVersion: - "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string" - ? scopeSpan.scope.version - : undefined, + scopeName: scope.name, + scopeVersion: typeof scope.version === "string" ? scope.version : undefined, span, }), ); @@ -747,9 +746,9 @@ function otlpSpanToTraceRecord(input: { }; } -function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] { - const code = String(input.code); - const message = input.message; +function decodeStatus(input: OtlpSpanStatus | undefined): OtlpTraceRecord["status"] { + const code = String(input?.code ?? 0); + const message = input?.message; return { code, @@ -757,16 +756,20 @@ function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] { }; } -function decodeEvents(input: ReadonlyArray): ReadonlyArray { - return input.map((current) => ({ +function decodeEvents( + input: ReadonlyArray | undefined, +): ReadonlyArray { + return (input ?? []).map((current) => ({ name: current.name, timeUnixNano: current.timeUnixNano, attributes: decodeAttributes(current.attributes), })); } -function decodeLinks(input: ReadonlyArray): ReadonlyArray { - return input.flatMap((current) => { +function decodeLinks( + input: ReadonlyArray | undefined, +): ReadonlyArray { + return (input ?? []).flatMap((current) => { const traceId = current.traceId; const spanId = current.spanId; return { @@ -778,11 +781,11 @@ function decodeLinks(input: ReadonlyArray): ReadonlyArray, + input: ReadonlyArray | undefined, ): Readonly> { const entries: Record = {}; - for (const attribute of input) { + for (const attribute of input ?? []) { entries[attribute.key] = decodeValue(attribute.value); } @@ -809,7 +812,7 @@ function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown { return input.bytesValue; } if (input.arrayValue) { - return input.arrayValue.values.map((entry) => decodeValue(entry)); + return (input.arrayValue.values ?? []).map((entry) => decodeValue(entry)); } if (input.kvlistValue) { return decodeAttributes(input.kvlistValue.values); From e03b6967a914d67bc4314e3f0e400037b9846695 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:26:32 -0500 Subject: [PATCH 061/158] fix(server): keep the live search index when a refresh times out (#23182) Co-authored-by: Claude Opus 5.5 --- .../src/workspace/WorkspaceEntries.test.ts | 48 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 9 ++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 6bef5f04263c..d861935fe17c 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -673,6 +674,53 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); }), ); + + it.effect("keeps answering from the live index when a refresh outlasts the scan wait", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-" }); + yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n"); + let scansOutlastWait = false; + let finders = 0; + let rescans = 0; + const createFinder = FileFinder.create.bind(FileFinder); + vi.spyOn(FileFinder, "create").mockImplementation((options) => { + const created = createFinder(options); + if (created.ok) { + const finder = created.value; + finders += 1; + const scanFiles = finder.scanFiles.bind(finder); + finder.scanFiles = () => { + rescans += 1; + return scanFiles(); + }; + const waitForIndexReady = finder.waitForIndexReady.bind(finder); + finder.waitForIndexReady = (timeoutMs) => + scansOutlastWait + ? Promise.resolve({ ok: true, value: false }) + : waitForIndexReady(timeoutMs); + } + return created; + }); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searchNeedle = workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + yield* searchNeedle; + + scansOutlastWait = true; + yield* workspaceEntries.refresh(cwd); + const result = yield* searchNeedle; + + expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]); + // The second search ran on the rescanned live finder, not a rebuild. + expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 }); + }), + ); }); describe("browse", () => { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8f2b64cf26a3..d5539e2f4e71 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -117,10 +117,7 @@ export class WorkspaceSearchIndex extends Context.Service< readonly searchContents: ( input: Omit, ) => Effect.Effect; - readonly refresh: () => Effect.Effect< - void, - WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut - >; + readonly refresh: () => Effect.Effect; } >()("t3/workspace/WorkspaceSearchIndex") {} @@ -419,6 +416,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason: result.error, }); } + // A rescan that outlasts the wait keeps running, and the finder answers + // from its previous index until it finishes, so the index stays usable. yield* waitForIndexReady( cwd, finder, @@ -428,7 +427,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason, cause, }), - ); + ).pipe(Effect.catchTags({ WorkspaceSearchIndexScanTimedOut: () => Effect.void })); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( From d3f6ace49625708d054bd63642ee87c35a6dc5b5 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:26:48 -0500 Subject: [PATCH 062/158] fix(web): respect provider permissions in bulk updates (#23183) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../ProviderUpdatesAction.browser.test.tsx | 153 ++++++++++++++++++ .../src/components/ProviderUpdatesAction.tsx | 9 +- 2 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ProviderUpdatesAction.browser.test.tsx diff --git a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx new file mode 100644 index 000000000000..d6ca540686f0 --- /dev/null +++ b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx @@ -0,0 +1,153 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + AuthProvidersManageScope, + type AuthSessionState, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; + +const state = vi.hoisted(() => ({ + environments: [] as { + environmentId: EnvironmentId; + label: string; + connection: { phase: "connected" | "disconnected" }; + }[], + providers: new Map>(), + requests: [] as EnvironmentId[], + sessions: new Map>>(), +})); + +vi.mock(import("~/state/environments"), () => ({ + useEnvironments: (() => ({ environments: state.environments })) as never, + useEnvironmentProviders: () => state.providers, +})); +vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { + const actual = await original(); + return { + ...actual, + createEnvironmentSessionAtoms: (( + ...args: Parameters + ) => ({ + ...actual.createEnvironmentSessionAtoms(...args), + sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, + })) as typeof actual.createEnvironmentSessionAtoms, + }; +}); +vi.mock(import("~/state/server"), () => ({ + serverEnvironment: { updateProvider: {} } as never, +})); +vi.mock(import("~/state/use-atom-command"), () => ({ + useAtomCommand: (() => + async ({ environmentId }: { environmentId: EnvironmentId }) => { + state.requests.push(environmentId); + return AsyncResult.success(undefined); + }) as never, +})); + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useProviderUpdates } from "./ProviderUpdatesAction"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-21T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "npm install -g @openai/codex", + canUpdate: true, + checkedAt: "2026-10-21T00:00:00.000Z", + message: "Update available.", + }, +}; + +function session( + scopes: NonNullable, +): AsyncResult.AsyncResult { + return AsyncResult.success({ + authenticated: true, + scopes, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, + }); +} + +function setup() { + state.environments = [ + { environmentId: EnvironmentId.make("read-only"), label: "Read only" }, + { environmentId: EnvironmentId.make("writable"), label: "Writable" }, + { environmentId: EnvironmentId.make("unresolved"), label: "Unresolved" }, + ].map((environment) => ({ ...environment, connection: { phase: "connected" } })); + state.providers = new Map( + state.environments.map(({ environmentId }) => [environmentId, [provider]]), + ); + state.requests = []; + const sessions = new Map>>( + [ + [state.environments[0]!.environmentId, Atom.make(session(["orchestration:read"]))], + [state.environments[1]!.environmentId, Atom.make(session([AuthProvidersManageScope]))], + [ + state.environments[2]!.environmentId, + Atom.make>( + AsyncResult.initial(), + ), + ], + ], + ); + state.sessions = sessions; + return { sessions }; +} + +it("bulk provider updates exclude connections without a confirmed management grant", async () => { + const fixture = setup(); + let updates!: ReturnType; + function Probe() { + const nextUpdates = useProviderUpdates(); + useEffect(() => { + updates = nextUpdates; + }, [nextUpdates]); + return null; + } + await using _screen = await render( + + + , + ); + + const writableId = state.environments[1]!.environmentId; + expect(updates.machines.map(({ environmentId }) => environmentId)).toEqual([writableId]); + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([writableId]); + + await act(() => { + appAtomRegistry.set(fixture.sessions.get(writableId)!, session(["orchestration:read"])); + }); + expect(updates.machines).toEqual([]); + state.requests = []; + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([]); +}); diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 8d45d66b7121..57573806e45d 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -1,9 +1,10 @@ -import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { AuthProvidersManageScope, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { useRef, useState } from "react"; import { useEnvironmentProviders, useEnvironments } from "~/state/environments"; import { serverEnvironment } from "~/state/server"; +import { useEnvironmentsWithScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { canOneClickUpdateProviderCandidate, @@ -17,13 +18,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function useProviderUpdates() { const { environments } = useEnvironments(); + const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope); const providersByEnvironment = useEnvironmentProviders(); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); const machines = environments.flatMap((environment) => { const providers = providersByEnvironment.get(environment.environmentId) ?? []; - if (environment.connection.phase !== "connected") { + if ( + environment.connection.phase !== "connected" || + !manageableIds.has(environment.environmentId) + ) { return []; } const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => From 1b8516e8747973397e7ed7371c764ba20db04719 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:27:03 -0500 Subject: [PATCH 063/158] fix(mobile): cap live work-log summaries for long approval prompts (#23227) Co-authored-by: GPT-6.1 Sol --- apps/mobile/src/lib/threadActivity.test.ts | 78 ++++++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 9 ++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 409631131c05..583cbe7098e1 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1842,3 +1842,81 @@ describe("streamed feed updates", () => { expect(showsDelegation(steps[3]!)).toBe(false); }); }); + +it.each([false, true])( + "caps live approval headers while preserving the prompt (grouped: %s)", + (grouped) => { + const at = "2026-06-20T00:00:02.000Z"; + const prompt = `python3 - <<'PY'\n${"print('approval')\n".repeat(400)}PY`; + const approval: OrchestrationV2TurnItem = { + ...base("long-approval", at, 2), + type: "approval_request", + status: "waiting", + completedAt: null, + requestId: RuntimeRequestId.make("long-approval"), + requestKind: "command", + prompt, + }; + const presented = deriveThreadFeedPresentation( + buildThreadFeed([ + projected(userMessage(), 0), + ...(grouped ? [projected(command(), 1)] : []), + projected(approval, 2), + ]), + { runId, status: "running", startedAt: at, completedAt: null }, + new Set(), + new Set(), + at, + ); + const header = presented.find((row) => + grouped ? row.type === "work-toggle" : row.type === "activity-group", + ); + const label = + header?.type === "work-toggle" + ? header.summary + : header?.type === "activity-group" + ? header.activities[0]?.groupStandIn?.label + : undefined; + expect(label).toBeDefined(); + expect(label!.length).toBeLessThanOrEqual(203); + expect(label).toMatch(/^python3 - <<'PY' print/); + expect(label).not.toContain("\n"); + const feed = buildThreadFeed([projected(approval, 0)]); + const activity = feed.flatMap((row) => + row.type === "activity-group" ? row.activities : [], + )[0]!; + expect(activity.detail).toBe(prompt); + expect(JSON.parse(activity.getFullDetail()!).item).toMatchObject({ prompt }); + expect(activity.getCopyText()).toContain(prompt); + }, +); + +it("caps live thought previews while preserving reasoning detail", () => { + const at = "2026-06-20T00:00:02.000Z"; + const text = "Considering the pending approval\n".repeat(100); + const thought: OrchestrationV2TurnItem = { + ...base("long-thought", at, 1), + type: "reasoning", + status: "running", + completedAt: null, + text, + streaming: true, + }; + const feed = buildThreadFeed([projected(userMessage(), 0), projected(thought, 1)]); + const rows = deriveThreadFeedPresentation( + feed, + { runId, status: "running", startedAt: at, completedAt: null }, + new Set(), + new Set(), + at, + ); + const header = rows.find((row) => row.type === "work-toggle"); + expect(header).toMatchObject({ summary: "Thinking", live: true }); + expect(header?.thought).toMatch(/^Considering the pending approval/); + expect(header?.thought).not.toContain("\n"); + expect(header!.thought!.length).toBeLessThanOrEqual(203); + const activity = feed.flatMap((row) => (row.type === "activity-group" ? row.activities : []))[0]!; + expect(activity.detail).toBe(text); + expect(JSON.parse(activity.getFullDetail()!).item).toMatchObject({ text }); + expect(activity.getCopyText()).toContain(text); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index bd7af44b2fa0..91681731aab1 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1774,7 +1774,7 @@ function appendToolGroupRows( (activity.workEntry.detail?.trim() ?? "") !== "", )?.workEntry.detail : undefined; - const thought = latestThought ? liveThoughtLine(latestThought) : ""; + const thought = latestThought ? compactWorkEntryText(liveThoughtLine(latestThought)) : ""; const summary = live ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning" ? latestActivity.lifecycleStatus === "inProgress" @@ -1872,9 +1872,8 @@ function appendToolGroupRows( function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string { const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense); if (activity.workEntry.itemType === "reasoning") { - return ( - activity.workEntry.detail?.trim().replace(/\s+/g, " ") || - (status === "inProgress" ? "Thinking" : "Thought") + return compactWorkEntryText( + activity.workEntry.detail?.trim() || (status === "inProgress" ? "Thinking" : "Thought"), ); } const presentation = resolveWorkEntryToolPresentation({ @@ -1897,7 +1896,7 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo : "Ran"; return `${verb} ${program ?? "command"}`; } - return activity.detail ?? activity.summary; + return compactWorkEntryText(activity.detail ?? activity.summary); } interface ThreadFeedBuild { From d3315d26e9165e20c89b0943811da25b5f846f2f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:27:18 -0500 Subject: [PATCH 064/158] fix(web): keep project name edits open during IME composition (#23231) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- .../ProjectSettingsPanel.browser.test.tsx | 33 +++++++++++++++++++ .../settings/ProjectSettingsPanel.tsx | 1 + 2 files changed, 34 insertions(+) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 077aef4a4c8c..d6e20862d6c3 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -628,3 +628,36 @@ it("returns to the project overview after removal, clearing the selection but ke expect(screen.router.state.location.search).toEqual({ machine: remoteId }); }); }); + +describe("project name composition", () => { + it.each([ + { isComposing: true, keyCode: 13 }, + { isComposing: false, keyCode: 229 }, + ])("does not save a partial name for $isComposing/$keyCode", async (composition) => { + setup(); + await using screen = await mountPanel(); + const input = nameInput(screen); + await userEvent.fill(input, "日本"); + await act(async () => { + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, ...composition }), + ); + }); + expect(state.update).not.toHaveBeenCalled(); + await expect.element(input).toHaveFocus(); + + await userEvent.type(input, "語"); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2)); + expect(state.projects.map((project) => project.title)).toEqual(["日本語", "日本語"]); + await expect.element(nameInput(screen)).toHaveValue("日本語"); + + await userEvent.fill(nameInput(screen), "日本語の名前"); + await userEvent.keyboard("{Tab}"); + await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(4)); + expect(state.projects.map((project) => project.title)).toEqual([ + "日本語の名前", + "日本語の名前", + ]); + }); +}); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 46d633d0f148..a67e914182e0 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -464,6 +464,7 @@ function ProjectDetail({ void renameGroup(event.currentTarget.value, wasEdited); }} onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter") event.currentTarget.blur(); }} /> From 3d74f39a28cc47925878fa33d8b5f181aac0ded2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:27:33 -0500 Subject: [PATCH 065/158] fix(server): put the updated runtime on the macOS service PATH (#23239) Co-authored-by: Claude Opus 5.5 --- apps/server/src/cloud/bootService.test.ts | 28 ++++++++++++++++++-- apps/server/src/cloud/bootService.ts | 31 +++++++++++++++-------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 2edd11064801..8fcb026d4a45 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -170,6 +170,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( cliVersion = "1.2.3", serviceBaseDir = baseDir, insideService = false, + hostExecPath = "/usr/bin/t3", ) => Effect.gen(function* () { const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform); @@ -180,7 +181,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( baseDir: serviceBaseDir, logsDir: path.join(serviceBaseDir, "userdata", "logs"), cliVersion, - host: { execPath: "/usr/bin/t3" }, + host: { execPath: hostExecPath }, }); }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), @@ -188,7 +189,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( Layer.mergeAll( Layer.succeed(HostProcess.Platform, platform), Layer.succeed(HostProcess.UserId, 501), - Layer.succeed(HostProcess.ExecutablePath, "/usr/bin/t3"), + Layer.succeed(HostProcess.ExecutablePath, hostExecPath), Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("no release download expected")), @@ -829,6 +830,29 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + // regression: #17532 + it.effect("puts the runtime an update installs on the launch agent PATH", () => + Effect.gen(function* () { + const { makeService, fs, baseDir, runtime } = yield* makeHarness("darwin"); + const path = yield* Path.Path; + const previous = runtime.versionDir; + const installed = pinnedRuntimePaths(path, baseDir, "1.2.4", "darwin").versionDir; + // The 1.2.3 CLI, or the 1.2.3 service's own PATH, installs 1.2.4. + const updated = yield* makeService( + `/usr/bin:${previous}:/bin`, + "1.2.4", + undefined, + false, + runtime.entryPath, + ); + const plan = yield* updated.install(); + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` PATH\n /usr/bin:${installed}:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin`, + ); + }), + ); + it.effect("drops PATH directories that cannot be represented in a launch agent plist", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness( diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 948d780c867c..efa18413d979 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -17,8 +17,10 @@ import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + launcherOwnsVersionsDir, pinnedRuntimeCommand, pinnedRuntimePaths, + pinnedRuntimeVersionsDir, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { @@ -542,6 +544,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const host = input.host ?? { execPath: hostExecPath }; + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); + const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); const xmlSafeInstallerDirectories = installerPath.split(":").filter( (directory) => directory.length > 0 && @@ -550,17 +554,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; }), ); + // During an update, the running CLI and a PATH inherited from the service name the runtime + // being replaced. The service and its agent shells should find the runtime it starts. const environmentPath = Array.from( - new Set([ - ...xmlSafeInstallerDirectories, - path.dirname(host.execPath), - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ]), + new Set( + [ + ...xmlSafeInstallerDirectories, + path.dirname(host.execPath), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ].map((directory) => + launcherOwnsVersionsDir(path, versionsDir, directory) ? runtimePaths.versionDir : directory, + ), + ), ).join(":"); const detectedManager = selectBootServiceManager({ @@ -574,7 +584,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); - const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { From a732caa7d2cbcc04363046dca2355e4d441b022e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:27:48 -0500 Subject: [PATCH 066/158] fix(server): Steer on a queued message keeps the model picked for it (#23240) Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 2 +- .../SteeringCompletion.integration.test.ts | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5f9c6ea3993e..d1838e7d10f7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9440,7 +9440,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, effects, projection, - modelSelection: projection.thread.modelSelection, + modelSelection: queuedRun.modelSelection, targetRunId: command.targetRunId, messageId: queuedMessage.id, text: queuedMessage.text, diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index d4867a264ec9..c09b7401df77 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1132,6 +1132,48 @@ it.effect("keeps a held commentary draft before the steer when turn end flushes ), ); +it.effect("steers a promoted queued message with the selection it was queued with", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-queued-selection", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("queued"), + threadId, + messageId: MessageId.make("message:queued"), + text: "queued", + attachments: [], + modelSelection: composerSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["queued"]); + assert.deepEqual(projection.thread.modelSelection, composerSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("keeps a queued message queued when promoted into a run that Stop reached", () => Effect.scoped( Effect.gen(function* () { From 2a4d1288c53b88f5494f1fac0c1ec568dae6d10a Mon Sep 17 00:00:00 2001 From: voida6 Date: Sun, 1 Nov 2026 13:28:03 +1100 Subject: [PATCH 067/158] fix(web): line up the Date and Time fields in custom snooze (#23360) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: maria-rcks --- apps/web/src/components/CustomSnoozeDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 43c466184bf2..91d4a54fd9e6 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -124,7 +124,7 @@ function CustomSnoozeDialog() {
{mode === "date" ? (
-
+
Date: Sun, 1 Nov 2026 03:28:18 +0100 Subject: [PATCH 068/158] fix(ssh): stop asking for a password when the SSH agent can't sign (#23437) Co-authored-by: maria-rcks --- packages/ssh/src/auth.test.ts | 35 ++++++++++++++- packages/ssh/src/auth.ts | 8 ++++ packages/ssh/src/tunnel.test.ts | 80 ++++++++++++++++++++++++++++++++- packages/ssh/src/tunnel.ts | 2 +- 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index 439350d6010a..898f365e53ed 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import { buildSshChildEnvironment, isSshAuthFailure } from "./auth.ts"; +import { buildSshChildEnvironment, isSshAgentSigningFailure, isSshAuthFailure } from "./auth.ts"; describe("ssh auth", () => { it.effect("detects ssh auth failures from common permission denied messages", () => @@ -30,6 +30,39 @@ describe("ssh auth", () => { }), ); + it.effect("detects agent signing failures from current and pre-8.2 OpenSSH", () => + Effect.sync(() => { + const denied = "julius@devbox: Permission denied (publickey,password)."; + assert.isTrue( + isSshAgentSigningFailure( + new Error( + `sign_and_send_pubkey: signing failed for ED25519 "/home/julius/.ssh/id_ed25519" from agent: communication with agent failed\r\n${denied}`, + ), + ), + ); + assert.isTrue( + isSshAgentSigningFailure( + new Error(`sign_and_send_pubkey: signing failed: agent refused operation\n${denied}`), + ), + ); + assert.isTrue( + isSshAgentSigningFailure( + new Error( + `sign_and_send_pubkey: signing failed: communication with agent failed\n${denied}`, + ), + ), + ); + assert.isFalse( + isSshAgentSigningFailure( + new Error( + `sign_and_send_pubkey: signing failed for ED25519-SK "/home/julius/.ssh/id_ed25519_sk": device not found\n${denied}`, + ), + ), + ); + assert.isFalse(isSshAgentSigningFailure(new Error(denied))); + }), + ); + it.effect("creates askpass env for cached password prompts", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index a759063e1df8..df2438232394 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -246,3 +246,11 @@ export function isSshAuthFailure(error: unknown): boolean { /too many authentication failures/u.test(normalized) ); } + +// A local agent signing failure cannot be remedied by the remote account password. +export function isSshAgentSigningFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /sign_and_send_pubkey: signing failed(?: for [^\n]* from agent:|: (?:agent refused operation|communication with agent failed))/u.test( + message, + ); +} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 360009a6063e..6cea42d131a6 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -15,7 +15,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as SshAuth from "./auth.ts"; -import { SshCommandError } from "./errors.ts"; +import { SshCommandError, SshPasswordPromptError } from "./errors.ts"; import * as SshTunnel from "./tunnel.ts"; const makeSuccessfulProcess = (stdout: string) => { @@ -260,6 +260,84 @@ describe("ssh tunnel scripts", () => { }), ); + it.effect.each([ + { + name: "does not ask for a password when the agent cannot sign an accepted key", + stderr: + 'sign_and_send_pubkey: signing failed for ED25519 "/home/julius/.ssh/id_ed25519" from agent: communication with agent failed\r\n' + + "julius@devbox: Permission denied (publickey,password).\r\n", + expectedPromptAttempts: [], + }, + { + name: "asks for a password when signing failure has no agent provenance", + stderr: + 'sign_and_send_pubkey: signing failed for ED25519-CERT "key-cert.pub": agent refused operation\n' + + "julius@devbox: Permission denied (publickey,password).\n", + expectedPromptAttempts: [1], + }, + { + name: "asks for a password when the server rejects every key", + stderr: "julius@devbox: Permission denied (publickey,password).\r\n", + expectedPromptAttempts: [1], + }, + ])("$name", ({ stderr, expectedPromptAttempts }) => + Effect.gen(function* () { + const promptAttempts: Array = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.succeed( + commandArgs(command).includes("-G") + ? makeSuccessfulProcess("") + : { + ...makeSuccessfulProcess(""), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(255)), + stderr: Stream.make(new TextEncoder().encode(stderr)), + }, + ), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + Layer.succeed( + SshAuth.SshPasswordPrompt, + SshAuth.SshPasswordPrompt.of({ + isAvailable: true, + request: (request) => + Effect.sync(() => { + promptAttempts.push(request.attempt); + return null; + }), + }), + ), + SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), + ); + yield* Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const result = yield* Effect.result( + manager.ensureEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }), + ); + assert.isTrue(Result.isFailure(result)); + assert.deepEqual(promptAttempts, expectedPromptAttempts); + if (Result.isFailure(result)) { + if (expectedPromptAttempts.length === 0) { + assert.instanceOf(result.failure, SshCommandError); + if (result.failure instanceof SshCommandError) { + assert.equal(result.failure.stderr, stderr); + } + } else { + assert.instanceOf(result.failure, SshPasswordPromptError); + } + } + }).pipe(Effect.provide(layer), Effect.scoped); + }), + ); + it.effect.each(["local tunnel", "remote server"] as const)( "waits for %s shutdown before reconnecting the same target", (stalledStep) => diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 1a98fa7d21a7..d2ee6b39a9a2 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -1454,7 +1454,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma if (input.authSecret !== null) { authSecrets.delete(input.key); } - if (input.promptCount >= 2) { + if (input.promptCount >= 2 || SshAuth.isSshAgentSigningFailure(input.error)) { return yield* input.error; } From b0aa468aa18ca4195d08362a83a9c6235114633a Mon Sep 17 00:00:00 2001 From: Kayzel Date: Sun, 1 Nov 2026 03:28:33 +0100 Subject: [PATCH 069/158] fix(web): copy and save images embedded as data URLs (#23621) Co-authored-by: maria-rcks --- .../src/components/media/mediaContent.test.ts | 57 +++++++++++++++++++ apps/web/src/components/media/mediaContent.ts | 35 ++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 apps/web/src/components/media/mediaContent.test.ts diff --git a/apps/web/src/components/media/mediaContent.test.ts b/apps/web/src/components/media/mediaContent.test.ts new file mode 100644 index 000000000000..fb95a7e0eacd --- /dev/null +++ b/apps/web/src/components/media/mediaContent.test.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from "vite-plus/test"; + +import { readMediaPng } from "./mediaContent"; + +const PNG_BYTES = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +it("copies a data URL image without fetching it", async () => { + // The desktop renderer's connect-src rejects data: URLs the way fetch rejects a CORS failure. + using fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); + const src = `data:image/png;base64,${btoa(String.fromCharCode(...PNG_BYTES))}`; + + const png = await readMediaPng(src); + + expect(fetch).not.toHaveBeenCalled(); + expect(png.type).toBe("image/png"); + expect([...new Uint8Array(await png.arrayBuffer())]).toEqual(PNG_BYTES); +}); + +it("decodes percent-escaped bytes in a non-base64 data URL", async () => { + const escaped = PNG_BYTES.map((byte) => `%${byte.toString(16).padStart(2, "0")}`).join(""); + const src = `data:image/png,${escaped}`; + + const png = await readMediaPng(src); + + expect([...new Uint8Array(await png.arrayBuffer())]).toEqual(PNG_BYTES); +}); + +it("accepts any case in the scheme and base64 marker, and escapes in base64", async () => { + const base64 = btoa(String.fromCharCode(...PNG_BYTES)).replaceAll("=", "%3D"); + + const png = await readMediaPng(`DATA:image/png;BASE64,${base64}`); + + expect([...new Uint8Array(await png.arrayBuffer())]).toEqual(PNG_BYTES); +}); + +it("ignores the URL fragment when decoding a data URL", async () => { + const src = `data:image/png;base64,${btoa(String.fromCharCode(...PNG_BYTES))}#preview`; + + const png = await readMediaPng(src); + + expect([...new Uint8Array(await png.arrayBuffer())]).toEqual(PNG_BYTES); +}); + +it.each(["data:image/png;base64,%%%", "data:image/png"])( + "reports malformed inline media: %s", + async (src) => { + await expect(readMediaPng(src)).rejects.toThrow("This inline media could not be decoded."); + }, +); + +it("keeps remote media on the fetch path", async () => { + const png = new Blob([new Uint8Array(PNG_BYTES)], { type: "image/png" }); + using fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(png)); + + expect(await readMediaPng("https://example.com/image.png")).toEqual(png); + expect(fetch).toHaveBeenCalledWith("https://example.com/image.png"); +}); diff --git a/apps/web/src/components/media/mediaContent.ts b/apps/web/src/components/media/mediaContent.ts index 7c3128ca28eb..4b1edab6a3d4 100644 --- a/apps/web/src/components/media/mediaContent.ts +++ b/apps/web/src/components/media/mediaContent.ts @@ -6,7 +6,42 @@ export function resolveProtocolRelativeMediaUrl(src: string): string { return `${protocol}${src}`; } +// Data URL escapes are raw bytes, not UTF-8, so `%89` must stay one byte. +function decodePercentBytes(payload: string) { + const encoder = new TextEncoder(); + return Uint8Array.from( + payload + .split(/(%[0-9a-f]{2})/i) + .flatMap((part) => + /^%[0-9a-f]{2}$/i.test(part) + ? [Number.parseInt(part.slice(1), 16)] + : [...encoder.encode(part)], + ), + ); +} + +/** Decodes inline media locally; desktop connect-src omits `data:`, so fetch() cannot read it. */ +function readDataUrlBlob(src: string): Blob { + // A fragment is never part of the payload; a literal `#` in data must be escaped as `%23`. + const url = src.split("#", 1)[0] ?? src; + const comma = url.indexOf(","); + const [mimeType = "", ...parameters] = url.slice("data:".length, comma).split(";"); + const payload = url.slice(comma + 1); + try { + if (comma < 0) throw new Error("Missing data URL payload."); + const isBase64 = parameters.some((parameter) => parameter.trim().toLowerCase() === "base64"); + // Base64 text is ASCII, so any escape that is not valid UTF-8 is malformed anyway. + const bytes = isBase64 + ? Uint8Array.from(atob(decodeURIComponent(payload)), (char) => char.charCodeAt(0)) + : decodePercentBytes(payload); + return new Blob([bytes], { type: mimeType || "text/plain" }); + } catch (cause) { + throw new Error("This inline media could not be decoded.", { cause }); + } +} + async function readMediaBlob(src: string): Promise { + if (/^data:/i.test(src)) return readDataUrlBlob(src); let response: Response; try { response = await fetch(src); From e5a2031a7398725e4aeaf4e7bf06045c5955d733 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:28:48 -0500 Subject: [PATCH 070/158] fix(server): review guide stops no longer show the same lines twice (#23725) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../pullRequestReviewGuideNormalize.test.ts | 42 +++++++++++++++++++ .../pullRequestReviewGuideNormalize.ts | 33 ++++++++------- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts index a47743b34263..92704c51102f 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -152,6 +152,48 @@ describe("normalizeReviewGuide", () => { expect(result.stops).toHaveLength(1); }); + it("merges excerpts that a later excerpt bridges into one", () => { + const result = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [ + excerpt("src/a.ts", 40, 41), + { ...excerpt("src/a.ts", 50, 51), caption: "Second" }, + excerpt("src/a.ts", 41, 50), + ], + }), + ], + }), + change, + ); + expect(result.stops[0]?.excerpts).toEqual([ + { ...excerpt("src/a.ts", 40, 51), caption: "Second" }, + ]); + + const tailNote = { path: "src/a.ts", line: 40, title: "Tail", body: "Keep this note." }; + const wide = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [ + excerpt("src/a.ts", 1, 25), + { ...excerpt("src/a.ts", 30, 45), caption: "Second" }, + excerpt("src/a.ts", 24, 32), + ], + notes: [tailNote], + }), + ], + }), + parseReviewGuideChange( + "src/a.ts\0", + "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -0,0 +1,60 @@\n+added", + ), + ); + expect(wide.stops[0]?.excerpts).toEqual([{ ...excerpt("src/a.ts", 1, 25), caption: "Second" }]); + expect(wide.stops[0]?.notes).toEqual([]); + }); + it("drops notes outside the stop's remaining excerpts", () => { const result = normalizeReviewGuide( guide({ diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts index 184908e4bd7f..82edfc5fb815 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts @@ -87,26 +87,29 @@ function clampExcerpt( function mergeExcerpts( excerpts: ReadonlyArray, ): Array { - const merged: Array = []; + let merged: Array = []; for (const excerpt of excerpts) { - const index = merged.findIndex( - (other) => - other.path === excerpt.path && - other.side === excerpt.side && - other.start <= excerpt.end + 1 && - excerpt.start <= other.end + 1, - ); - const other = merged[index]; - if (other === undefined) { + const touches = (other: PullRequestReviewGuideExcerpt) => + other.path === excerpt.path && + other.side === excerpt.side && + other.start <= excerpt.end + 1 && + excerpt.start <= other.end + 1; + // An excerpt can bridge several earlier ones; fold them all into the first. + const group = merged.filter(touches); + const first = group[0]; + if (first === undefined) { merged.push(excerpt); continue; } - merged[index] = { - ...other, - start: Math.min(other.start, excerpt.start), - end: Math.max(other.end, excerpt.end), - caption: other.caption || excerpt.caption, + const combined = { + ...first, + start: Math.min(excerpt.start, ...group.map((other) => other.start)), + end: Math.max(excerpt.end, ...group.map((other) => other.end)), + caption: group.find((other) => other.caption)?.caption ?? excerpt.caption, }; + merged = merged.flatMap((other) => + other === first ? [combined] : touches(other) ? [] : [other], + ); } return merged.map((excerpt) => ({ ...excerpt, From b4ae3c8f9cc1d742f1f744d7b0fbfb7c1d2efcf3 Mon Sep 17 00:00:00 2001 From: Koushik_xd <122906171+koushikxd@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:59:02 +0530 Subject: [PATCH 071/158] fix(muse): list background children under their turn item's native id (#23856) --- .../provider-muse/src/server/adapter.test.ts | 53 +++++++++++++++++++ packages/provider-muse/src/server/adapter.ts | 17 ++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 3baeabbbe3c1..da740b92d2e8 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -1739,6 +1739,59 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("lists a running subagent under its turn item's native id as Muse reveals it", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + const revisions = [ + { revision: 1, status: "inProgress" }, + { revision: 2, status: "inProgress", subagentId: "" }, + { revision: 3, status: "inProgress", subagentId: "subagent-1" }, + { + revision: 4, + status: "inProgress", + subagentId: "subagent-1", + childSessionId: "child-session-1", + }, + { + revision: 5, + status: "completed", + subagentId: "subagent-1", + childSessionId: "child-session-1", + }, + ]; + const listed: Array<{ nativeId: string | null | undefined; roster: Array }> = []; + for (const fields of revisions) { + yield* fake.emit(fields.revision === 1 ? "item/started" : "item/updated", { + turnId: nativeId, + item: { itemId: "sub-1", kind: "subagent", turnId: nativeId, ...fields }, + }); + const item = yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "subagent", + ); + const roster = harness.allEvents.findLast( + (event) => event.type === "provider_thread.updated", + ); + listed.push({ + nativeId: item.turnItem.nativeItemRef?.nativeId, + roster: + roster?.type === "provider_thread.updated" + ? (roster.providerThread.pendingBackgroundTasks ?? []).map((task) => task.taskId) + : [], + }); + } + assert.deepStrictEqual(listed, [ + { nativeId: "sub-1", roster: ["sub-1"] }, + { nativeId: "sub-1", roster: ["sub-1"] }, + { nativeId: "subagent-1", roster: ["subagent-1"] }, + { nativeId: "child-session-1", roster: ["child-session-1"] }, + { nativeId: "child-session-1", roster: [] }, + ]); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("keeps a workflow child's first label after later revisions drop it", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index f022bf407fb1..575562948c94 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -248,6 +248,9 @@ const nativeRef = (nativeId: string) => ({ nativeId, strength: "strong" as const, }); +/** The roster lists a child under its turn item's native id, so readers can pair the two. */ +const backgroundTaskId = (item: MuseItem) => + item.kind === "subagent" ? item.childSessionId || item.subagentId || item.itemId : item.itemId; const recordSchema = Schema.Record(Schema.String, Schema.Unknown); const INFORMATIONAL_NOTIFICATIONS = new Set([ "session/contextUsage", @@ -597,9 +600,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( item.result?.summary ?? (status === "failed" ? item.failureReason : undefined); const result = resultText ?? null; - const nativeTaskRef = item.childSessionId - ? nativeRef(item.childSessionId) - : nativeRef(item.subagentId ?? item.itemId); + const nativeTaskRef = nativeRef(backgroundTaskId(item)); const subagent: OrchestrationV2Subagent = { id: base.nodeId, threadId: base.threadId, @@ -677,7 +678,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( const description = item?.objective ?? item?.entryId ?? item?.fallbackText; return [ { - taskId: itemId, + taskId: item ? backgroundTaskId(item) : itemId, kind: item?.kind === "subagent" ? ("subagent" as const) : ("background_task" as const), ...(description ? { description } : {}), @@ -716,7 +717,13 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( ? "cancelled" : "failed", }); - if (wasRunning !== observedChildren.has(item.itemId)) yield* syncBackground(); + const running = observedChildren.has(item.itemId); + // Muse reveals a subagent's ids over several revisions, so a running child is + // relisted whenever the id its turn item carries moves. + const listed = thread?.pendingBackgroundTasks?.some( + (task) => task.taskId === backgroundTaskId(item), + ); + if (wasRunning !== running || (running && !listed)) yield* syncBackground(); } const streaming = status === "running"; const base = { From cedf8b4c1cd597e62b97ff3f2626d641724755b8 Mon Sep 17 00:00:00 2001 From: Saikrishna Ambeti Date: Sun, 1 Nov 2026 07:59:18 +0530 Subject: [PATCH 072/158] fix(web): format inline code when the opening backtick is typed last (#23940) --- apps/web/src/composer-rich-text-doc.test.ts | 50 +++++++++++++++++++++ apps/web/src/composer-rich-text-doc.ts | 37 ++++++++++++--- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 3ad569e4b8a4..ae120c8a9255 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -1028,6 +1028,56 @@ describe("inline code entry", () => { } } + it.each(["", "this is a "])( + "formats code after %j when the opening backtick is typed last", + (prefix) => { + const editor = editorFor(); + type(editor, `${prefix}test\` after`); + editor.commands.setTextSelection(prefix.length + 1); + type(editor, "`"); + + expect(editor.state.doc.textContent).toBe(`${prefix}test after`); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`test\` after`); + expect(editor.state.selection.from).toBe(prefix.length + 1); + type(editor, "new "); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`new test\` after`); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + type(editor, " plain"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`new test\` after plain`); + const formatted = editor.state.doc.toJSON(); + let undos = 0; + while (editor.commands.undo()) undos += 1; + expect(undos).toBeGreaterThan(0); + expect(editor.state.doc.textContent).toBe(""); + for (let i = 0; i < undos; i += 1) expect(editor.commands.redo()).toBe(true); + expect(editor.state.doc.toJSON()).toEqual(formatted); + }, + ); + + it.each([ + { name: "doubled closing ticks", text: "test``" }, + { name: "a code block", text: "test`", block: "codeBlock" }, + { name: "bold text", text: "test`", mark: "bold" }, + { name: "existing inline code", text: "test`", mark: "code" }, + ])("keeps an opening tick literal before $name", ({ text, block, mark }) => { + const editor = editorFor(block, mark); + type(editor, text); + editor.commands.setTextSelection(1); + type(editor, "`"); + expect(editor.state.doc.textContent).toBe(`\`${text}`); + }); + + it.each(["hardBreak", "composer-mention"])("does not pair ticks across a %s", (nodeType) => { + const editor = editorFor(); + type(editor, "before"); + editor.commands.insertContent({ type: nodeType }); + type(editor, "after`"); + editor.commands.setTextSelection(1); + type(editor, "`"); + expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]); + expect(editor.state.doc.textContent).toBe("`beforeafter`"); + }); + it.each(["", "prefix ", "@README.md"])( "formats a filled pair after %j, leaves following text plain, and keeps the normal input rule", (prefix) => { diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index c0e0a653016e..2d629c3eb745 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -81,14 +81,37 @@ function codePairCaretPlugin(type: MarkType) { handleTextInput(view, from, to, text, insert) { if (view.composing || from !== to || text !== "`") return false; const $from = view.state.doc.resolve(from); - if ( - !$from.parent.isTextblock || - $from.parent.type.spec.code || - !/(^|[^`])`$/.test($from.nodeBefore?.text ?? "") - ) { - return false; + if (!$from.parent.isTextblock || $from.parent.type.spec.code) return false; + const before = $from.nodeBefore?.text ?? ""; + if (/(^|[^`])`$/.test(before)) { + view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 })); + return true; } - view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 })); + if (before.endsWith("`")) return false; + + // The opening tick can be typed after the closing one. Input rules + // cannot recognize that pair because they only read before the caret. + const after = $from.parent.textBetween( + $from.parentOffset, + $from.parent.content.size, + "\n", + "\n", + ); + const match = /^([^`\n]+)`(?!`)/.exec(after); + if (!match) return false; + const end = from + match[1]!.length; + let plain = true; + view.state.doc.nodesBetween(from, end + 1, (node) => { + if (node.isInline && (!node.isText || node.marks.length > 0)) plain = false; + }); + if (!plain) return false; + const tr = insert() + .delete(end + 1, end + 2) + .delete(from, from + 1); + tr.addMark(from, end, type.create()); + tr.setSelection(TextSelection.create(tr.doc, from)); + tr.addStoredMark(type.create()); + view.dispatch(tr); return true; }, }, From c3303fb7dce9fc7432ec91bd13783690252898f2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:29:33 -0500 Subject: [PATCH 073/158] fix(pi): log oversized RPC records instead of dropping them silently (#23947) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../provider-pi/src/server/adapter.test.ts | 33 ++++++++++++++++--- packages/provider-pi/src/server/rpc.ts | 25 +++++++++++--- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index face22d8c2fa..5c94de304020 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -27,6 +27,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; @@ -4295,8 +4296,16 @@ describe("PiAdapterV2", () => { }); describe("PiRpc framing", () => { - it.effect("reassembles records across chunk boundaries and strips CR", () => - Effect.gen(function* () { + it.effect("reassembles records and warns when dropping oversized records", () => { + const warnings: Array> = []; + const warned = Deferred.makeUnsafe(); + const logCapture = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn" && Array.isArray(message)) { + warnings.push(message); + Deferred.doneUnsafe(warned, Effect.void); + } + }); + return Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const spawner = ChildProcessSpawner.make(() => Effect.succeed( @@ -4327,9 +4336,11 @@ describe("PiRpc framing", () => { yield* push('{"type":"agent_'); yield* push('start"}\r\n{"type":"agent_settled"}\nnot json\n{"type":"queue_update"}\n'); - yield* push('{"type":"must_not_emit","text":"'); + const head = '{"type":"must_not_emit","text":"'; + yield* push(head); const chunk = new TextEncoder().encode("x".repeat(8 * 1024 * 1024)); for (let index = 0; index < 32; index++) yield* Queue.offer(stdout, chunk); + yield* Deferred.await(warned); yield* push('"}\n{"type":"after_oversized"}\n'); const first = yield* Queue.take(connection.events); @@ -4339,8 +4350,20 @@ describe("PiRpc framing", () => { const third = yield* Queue.take(connection.events); assert.equal(third["type"], "queue_update"); assert.equal((yield* Queue.take(connection.events))["type"], "after_oversized"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); + assert.lengthOf(warnings, 1); + const [text, annotations] = warnings[0] ?? []; + assert.equal(text, "Dropping oversized pi stdout record."); + assert.deepEqual(annotations, { + recordLength: head.length + 28 * chunk.length, + maxRecordLength: 232084864, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge(NodeServices.layer, Logger.layer([logCapture], { mergeWithExisting: false })), + ), + ); + }); }); describe("PiRpc early process exit", () => { diff --git a/packages/provider-pi/src/server/rpc.ts b/packages/provider-pi/src/server/rpc.ts index ed608613ac90..0af3fade61c3 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -96,14 +96,16 @@ const MAX_PI_RECORD_CHARS = function makeJsonlFramer() { let buffer = ""; let dropping = false; - return (chunk: string): ReadonlyArray => { + return (chunk: string) => { const lines: string[] = []; + const droppedLengths: number[] = []; let start = 0; while (start < chunk.length) { const newline = chunk.indexOf("\n", start); const end = newline < 0 ? chunk.length : newline; if (!dropping) { if (buffer.length + end - start > MAX_PI_RECORD_CHARS) { + droppedLengths.push(buffer.length + end - start); buffer = ""; dropping = true; } else { @@ -118,10 +120,22 @@ function makeJsonlFramer() { dropping = false; start = newline + 1; } - return lines; + return { lines, droppedLengths }; }; } +// Report the observed length before LF arrives; an unterminated response can time out first. +const logDroppedPiRecords = (droppedLengths: ReadonlyArray) => + Effect.forEach( + droppedLengths, + (recordLength) => + Effect.logWarning("Dropping oversized pi stdout record.", { + recordLength, + maxRecordLength: MAX_PI_RECORD_CHARS, + }), + { discard: true }, + ); + const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); @@ -294,7 +308,8 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp Stream.decodeText(), Stream.runForEach((chunk) => Effect.gen(function* () { - const lines = frame(chunk); + const { lines, droppedLengths } = frame(chunk); + yield* logDroppedPiRecords(droppedLengths); for (const line of lines) { const record = parsePiRecord(line); if (record === undefined) { @@ -308,7 +323,9 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp }), ), ); - for (const line of frame("\n")) { + const flushed = frame("\n"); + yield* logDroppedPiRecords(flushed.droppedLengths); + for (const line of flushed.lines) { const trailing = parsePiRecord(line); if (trailing !== undefined) yield* routeRecord(trailing); } From c2dc2eaaf5e66cf9ebebb49af7ca8fa7bd41597e Mon Sep 17 00:00:00 2001 From: Tomer Danan Date: Sun, 1 Nov 2026 04:29:48 +0200 Subject: [PATCH 074/158] fix(web,mobile): Hebrew and Arabic read right to left in questions, citations, attachments and diff comments (#24050) Co-authored-by: maria-rcks --- .../features/threads/PendingUserInputCard.tsx | 50 ++++++++- .../src/native/EnrichedMarkdownText.tsx | 16 +-- .../native/SelectableMarkdownText.types.ts | 2 + .../components/chat/AssistantCitationChip.tsx | 12 ++- .../chat/AssistantCitationCommentEditor.tsx | 2 + ...gUserInputPanel.direction.browser.test.tsx | 100 ++++++++++++++++++ .../chat/ComposerPendingUserInputPanel.tsx | 35 +++++- .../src/components/chat/MarkdownCodeBlock.tsx | 2 +- .../chat/MessagesTimeline.browser.test.tsx | 6 +- .../chat/TimelineUserMessageRow.tsx | 2 + .../diffs/DiffCommentAnnotation.tsx | 9 +- .../web/src/components/files/AudioPreview.tsx | 2 +- apps/web/src/textDirection.test.ts | 38 ++++++- apps/web/src/textDirection.ts | 28 +---- packages/shared/package.json | 4 + packages/shared/src/textDirection.ts | 34 ++++++ 16 files changed, 292 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx create mode 100644 packages/shared/src/textDirection.ts diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 9ecbbb70f1d8..523d8027519c 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -2,6 +2,11 @@ import { Button } from "../../components/ui/Button"; import { QuestionAttachments } from "./QuestionAttachments"; import type { EnvironmentId, RuntimeRequestId } from "@t3tools/contracts"; import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; +import { + markdownProseDirection, + textDirection, + type TextDirection, +} from "@t3tools/shared/textDirection"; import { useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { @@ -61,7 +66,19 @@ export interface PendingUserInputCardProps { /** Android cannot hit-test overlays beyond their parent's bounds; render its expanded card in-flow. */ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; -function QuestionText(props: { readonly markdown: string; readonly environmentId: EnvironmentId }) { +function proseDirectionStyle(text: string, setDirection: TextDirection | undefined) { + const direction = textDirection(text); + return { + ...(direction ? { direction, writingDirection: direction } : null), + textAlign: (direction === "rtl") === (setDirection === "rtl") ? "left" : "right", + } as const; +} + +function QuestionText(props: { + readonly markdown: string; + readonly environmentId: EnvironmentId; + readonly setDirection: TextDirection | undefined; +}) { const styles = useMarkdownPreviewStyles(); const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const openLink = useOpenPullRequest(); @@ -69,6 +86,11 @@ function QuestionText(props: { readonly markdown: string; readonly environmentId void openLink(href)} /> ); @@ -79,6 +101,15 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const canRespond = props.pendingUserInput.responseCapability !== "not_resumable"; const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId; const responseDisabled = !canRespond || isResponding; + const setDirection = markdownProseDirection( + props.pendingUserInput.questions + .flatMap((question) => [ + question.header, + question.question, + ...question.options.flatMap((option) => [option.label, option.description ?? ""]), + ]) + .join(" "), + ); const cardCoverage = props.cardCoverage; const barHeightRef = useRef(0); @@ -217,10 +248,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const draft = props.drafts[question.id]; return ( - + {question.header} - + {question.options.map((option) => { const optionValue = option.value ?? option.label.trim(); @@ -251,11 +289,15 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { "font-t3-bold text-sm", selected ? "text-foreground" : "text-foreground-secondary", )} + style={proseDirectionStyle(option.label, setDirection)} > {option.label} {description ? ( - + {description} ) : null} diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 13739f0f24a1..6fa8c229a953 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -264,13 +264,16 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { }; }, [props.contextClipboardFragment, props.markdown]); const { fontScale } = useWindowDimensions(); - const markdownStyle = useMemo( - () => ({ - ...enrichedStyle(props.textStyle, themeAppearance === "dark", fontScale), + const markdownStyle = useMemo(() => { + const style = enrichedStyle(props.textStyle, themeAppearance === "dark", fontScale); + return { + ...style, + paragraph: props.textAlign + ? { ...style.paragraph, textAlign: props.textAlign } + : style.paragraph, linkVariants, - }), - [props.textStyle, themeAppearance, fontScale, linkVariants], - ); + }; + }, [props.textStyle, props.textAlign, themeAppearance, fontScale, linkVariants]); const renderers = renderImage ? { image: (block: MarkdownImageBlock) => { @@ -312,6 +315,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { void; readonly marginTop?: number; readonly marginBottom?: number; + readonly writingDirection?: "ltr" | "rtl"; + readonly textAlign?: "left" | "right"; } diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index cbe5ad8cddb2..2b90b09f68e7 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -31,6 +31,7 @@ import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCom import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; import { observeResize } from "~/lib/observeResize"; +import { textDirection } from "~/textDirection"; export function AssistantCitationChip({ citation, @@ -130,7 +131,9 @@ export function AssistantCitationChip({ aria-label={`View cited assistant text: ${label}`} >
); } diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index d9fd60645618..0a8357bb9bab 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -1,5 +1,6 @@ import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; import { useState, type Ref } from "react"; +import { textDirection } from "~/textDirection"; import { Button } from "../ui/button"; @@ -51,6 +52,7 @@ export function AssistantCitationCommentEditor({ aria-invalid={commentTooLong || undefined} placeholder="Add an optional comment..." rows={2} + dir={textDirection(comment)} className="field-sizing-content block max-h-40 min-h-16 w-full resize-none bg-transparent px-1 py-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm" value={comment} onChange={(event) => { diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx new file mode 100644 index 000000000000..6e1b387a7412 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx @@ -0,0 +1,100 @@ +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +vi.mock(import("../ChatMarkdown"), () => ({ + default: (({ text }: { text: string }) =>

{text}

) as never, +})); + +import { render } from "../../../test/render"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +type Question = { + header: string; + question: string; + options: { label: string; description: string }[]; +}; + +const renderPanel = (questions: Question[], questionIndex = 0) => + render( + ({ + ...question, + id: `question-${index}`, + multiSelect: false, + })), + }, + ]} + cwd={undefined} + threadRef={{ environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }} + respondingRequestIds={[]} + answers={{}} + questionIndex={questionIndex} + onToggleOption={() => {}} + onAdvance={() => {}} + onDismiss={() => {}} + />, + ); + +it("reads a mostly Hebrew question set right to left", async () => { + await using screen = await renderPanel([ + { + header: "גישה", + question: "איך לבצע את המיגרציה?", + options: [ + { + label: "React Server Components בכל המסכים של האפליקציה", + description: "מסך אחד בכל פעם", + }, + { label: "Keep it on React 18", description: "English only" }, + ], + }, + ]); + + const text = (value: string) => screen.getByText(value, { exact: true }); + await expect.element(text("גישה")).toHaveStyle({ direction: "rtl" }); + await expect + .element(text("React Server Components בכל המסכים של האפליקציה")) + .toHaveStyle({ direction: "rtl" }); + await expect.element(text("מסך אחד בכל פעם")).toHaveStyle({ direction: "rtl" }); + await expect + .element(screen.getByRole("button", { name: /React Server/ })) + .toHaveStyle({ direction: "rtl" }); + await expect.element(text("Keep it on React 18")).toHaveStyle({ direction: "ltr" }); + + await screen.getByTitle("Hide the question and its options").click(); + await expect.element(text("איך לבצע את המיגרציה?")).toHaveStyle({ direction: "rtl" }); +}); + +it("keeps one direction for the whole set when a single question is Hebrew", async () => { + await using screen = await renderPanel( + [ + { + header: "Migration", + question: "How should we move to React Server Components?", + options: [ + { label: "Screen by screen", description: "Lower risk, each step tested alone." }, + { label: "Server Actions for all forms first", description: "Then the rest." }, + ], + }, + { + header: "גרסה", + question: "בחר גרסה", + options: [{ label: "גרסה 3 (חדשה!)", description: "!!!" }], + }, + ], + 1, + ); + + await expect + .element(screen.getByRole("button", { name: /גרסה 3/ })) + .toHaveStyle({ direction: "ltr" }); + await expect + .element(screen.getByText("גרסה 3 (חדשה!)", { exact: true })) + .toHaveStyle({ direction: "rtl" }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 37a002e2c270..a19e7f205cef 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,4 +1,5 @@ import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; +import { markdownProseDirection, textDirection } from "~/textDirection"; import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; @@ -179,8 +180,20 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return null; } + const promptDirection = markdownProseDirection( + prompt.questions + .flatMap((question) => [ + question.header, + question.question, + ...question.options.flatMap((option) => [option.label, option.description ?? ""]), + ]) + .join(" "), + ); + const questionTextAlign = promptDirection === "rtl" ? "text-right" : "text-left"; + return ( { setCollapsedQuestionId(open ? null : activeQuestion.id); @@ -197,13 +210,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( {activeQuestion.header} {isCollapsed ? ( <> - + {activeQuestion.question} @@ -245,7 +262,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( text={activeQuestion.question} cwd={cwd} threadRef={threadRef} - className="pt-2 text-foreground/85" + className={cn("pt-2 text-foreground/85", questionTextAlign)} /> {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
@@ -257,7 +274,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const isSelected = isOptimisticallySelected || draftSelectedOptions?.[index] === true; const className = cn( - "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 text-left sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", + questionTextAlign, + "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30", (disabled || isResponding) && "opacity-50 cursor-not-allowed", !disabled && !isResponding && "cursor-pointer", @@ -273,9 +291,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( )}
- {option.label} + + {option.label} + {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}
{index < 9 ? ( diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.tsx index 6b1822aac14a..301843c4ebe3 100644 --- a/apps/web/src/components/chat/MarkdownCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownCodeBlock.tsx @@ -65,7 +65,7 @@ export function MarkdownCodeBlockFrame({ return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 7fd73739d919..2fcaa596a5b6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -859,7 +859,7 @@ describe("MessagesTimeline attachments", () => { onOpen={onOpen} /> ); - await using screen = await renderElement(preview()); + await using screen = await renderElement(
{preview()}
); const audio = screen.container.querySelector("audio")!; const duration = vi.spyOn(audio, "duration", "get").mockReturnValue(Infinity); const play = vi.spyOn(audio, "play").mockResolvedValue(); @@ -884,6 +884,10 @@ describe("MessagesTimeline attachments", () => { audio.dispatchEvent(new Event("durationchange")); }); expect(screen.text()).toContain("0:03 / 0:10"); + expect( + getComputedStyle(screen.container.querySelector("audio")!.previousElementSibling!) + .direction, + ).toBe("ltr"); expect(progress.style.width).toBe("30%"); await act(() => { duration.mockReturnValue(NaN); diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index e73f5d1d1e9a..dfda2a18938e 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -74,6 +74,7 @@ import { import { chatMarkdownClipboardPayload } from "../../markdown-clipboard"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { cn } from "~/lib/utils"; +import { markdownProseDirection } from "~/textDirection"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; @@ -874,6 +875,7 @@ function UserTimelineRow({ row }: { row: Extract
{multiline ? metaLine : null} - {props.jumpLabel ? : null} {detailsTooltip} @@ -1448,6 +1451,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( "group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs", props.sweepAction !== null && "hidden", + jumpHintLabel && JUMP_HINT_SLOT_CLASS_NAME, )} > ) : null} + {jumpHintLabel ? : null} )} {props.sweepAction !== null ? dragDestination : null} @@ -1578,7 +1583,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{metaLine}
- {props.jumpLabel ? : null} {detailsTooltip} From 6525e4cb59e7582c7109e99bcaea60b0a6e53859 Mon Sep 17 00:00:00 2001 From: Claudesaul <139932571+Claudesaul@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:34:38 -0400 Subject: [PATCH 092/158] fix(web): maximize button no longer jumps when the right panel closes (#26232) Co-authored-by: maria-rcks --- apps/web/src/components/chat/PanelLayoutControls.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 675029840294..56d93123d7d6 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -190,9 +190,11 @@ export function WorkspaceTitlebarControls(props: { {props.showMaximizeControl ? ( Date: Sat, 31 Oct 2026 20:34:53 -0600 Subject: [PATCH 093/158] fix(acp): a lost or locked Copilot session restarts on the next message instead of failing every time (#26651) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../Adapters/AcpRegistryAdapterV2.test.ts | 169 ++++++++++++++++++ .../src/server/adapter.ts | 2 + .../src/server/copilotAcp.ts | 21 +++ packages/provider-acp/src/server/adapter.ts | 10 +- 4 files changed, 200 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 5a18843d709a..fa5b15bbc5fd 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2169,4 +2169,173 @@ describe("AcpRegistryAdapterV2", () => { assert.isFalse(yield* queued.runtime.hasPendingBackgroundWork!); }).pipe(Effect.provide(layerTest), Effect.scoped), ); + + it.effect.each([ + { + failure: "a lost session", + error: { + code: -32603, + message: "Internal error", + data: { + details: + "Request session.send failed with message: session event delivery failed: session not found: copilot-session", + }, + }, + restarts: true, + }, + { + failure: "a lost session reported in the message", + error: { + code: -32603, + message: + "Request session.send failed with message: session event delivery failed: session not found: copilot-session", + }, + restarts: true, + }, + { + failure: "a session-lock timeout", + error: { + code: -32603, + message: "Internal error", + data: { + details: + "session lock unavailable during is_session_generation_current: acquisition timed out", + }, + }, + restarts: true, + }, + { + failure: "a session-lock timeout reported in the message", + error: { + code: -32603, + message: + "Request session.send failed with message: session lock unavailable during is_session_generation_current: acquisition timed out", + }, + restarts: true, + }, + { + failure: "an unrelated internal error", + error: { + code: -32603, + message: "Request session.send failed with message: Session directory no longer exists", + }, + restarts: false, + }, + ] as const)("runs Copilot's next message after $failure", ({ error, restarts }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const replayDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-copilot-lost-" }); + const scriptPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-replay-agent.ts", import.meta.url), + ); + const request = (method: string, params: unknown = "") => ({ + type: "expect_outbound", + frame: { kind: "request", method, params }, + }); + const respond = (method: string, response: object) => ({ + type: "emit_inbound", + frame: { kind: "response", method, ...response }, + }); + const start = (sessionId: string) => [ + request("initialize"), + respond("initialize", { + result: { protocolVersion: 1, agentCapabilities: { loadSession: true } }, + }), + request("session/new"), + respond("session/new", { result: { sessionId } }), + ]; + const failedPrompt = [ + ...start("copilot-session"), + request("session/prompt"), + respond("session/prompt", { error }), + ]; + const completedPrompt = [ + request("session/prompt"), + respond("session/prompt", { result: { stopReason: "end_turn" } }), + ]; + const processes = restarts + ? [ + failedPrompt, + [ + ...start("replacement-session"), + request("session/load", { + sessionId: "copilot-session", + cwd: "", + mcpServers: "", + }), + respond("session/load", { result: {} }), + ...completedPrompt, + ], + ] + : [[...failedPrompt, ...completedPrompt]]; + let spawned = 0; + const instanceId = ProviderInstanceId.make("copilot-lost-session"); + const adapter = yield* makeAcpRegistryAdapterV2({ + selfInvocation: yield* resolveSelfInvocation(), + instanceId, + settings: yield* decodeAcpRegistryAdapterSettings({ agentId: "github-copilot-cli" }), + environment: {}, + makeRuntime: (runtimeInput) => + Effect.gen(function* () { + const index = spawned++; + const transcript = yield* decodeAcpReplayTranscript( + { + provider: ACP_REGISTRY_PROVIDER, + protocol: "acp.ndjson-jsonrpc", + version: "1", + scenario: `copilot-lost-session-${index}`, + entries: processes[index] as never, + }, + ACP_REGISTRY_PROVIDER, + ).pipe(Effect.orDie); + return yield* makeAcpReplayRuntime({ + transcript, + statusPath: path.join(replayDir, `status-${index}.json`), + scriptPath, + childProcessSpawner, + fileSystem, + })(runtimeInput); + }), + }).pipe(Effect.provide(layerInjectedRuntimeCatalog)); + const threadId = ThreadId.make("thread-copilot-lost-session"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: replayDir, + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("session-copilot-lost-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const runTurn = (ordinal: number) => + runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal }), + ) + .pipe( + Effect.andThen( + runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.take(1), + Stream.runCollect, + ), + ), + Effect.map(([terminal]) => terminal?.status), + ); + assert.equal(yield* runTurn(1), "failed"); + assert.equal(yield* runTurn(2), "completed"); + assert.equal(spawned, restarts ? 2 : 1); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); }); diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 6c2b4a9f6a14..4a3fb1ecd594 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -12,6 +12,7 @@ import { extractDroidElicitationForm } from "./droidAcp.ts"; import { copilotClientCapabilitiesMeta, extractCopilotSubagentUpdate, + isCopilotLostSessionError, makeCopilotSubagentRouting, } from "./copilotAcp.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; @@ -255,6 +256,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu registerExtensions: copilotRouting.registerExtensions, normalizeSessionUpdate: copilotRouting.normalizeSessionUpdate, extractSubagentUpdate: extractCopilotSubagentUpdate, + restartRuntimeAfterPromptError: isCopilotLostSessionError, }), makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(runtimeOptions, catalog), ...(runtimeCoordinator === undefined diff --git a/packages/provider-acp-registry/src/server/copilotAcp.ts b/packages/provider-acp-registry/src/server/copilotAcp.ts index 6414d37b0509..5f29c930e9a9 100644 --- a/packages/provider-acp-registry/src/server/copilotAcp.ts +++ b/packages/provider-acp-registry/src/server/copilotAcp.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type * as AcpSchema from "effect-acp/compat"; +import * as AcpErrors from "effect-acp/errors"; import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; import type { AcpAdapterV2ExtensionContext, @@ -24,6 +25,26 @@ export const copilotClientCapabilitiesMeta = { }, }; +const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); + +/** Copilot keeps failing every prompt with these until its process is replaced. */ +const COPILOT_LOST_SESSION_MARKERS = [ + "session event delivery failed: session not found: ", + "session lock unavailable during is_session_generation_current: acquisition timed out", +]; + +export function isCopilotLostSessionError(error: unknown): boolean { + if (!isAcpRequestError(error) || error.method !== "session/prompt" || error.code !== -32603) { + return false; + } + // Older Copilot reports the cause in data.details; its native ACP layer (1.0.105) in the message. + return [error.errorMessage, record(error.data)?.details].some( + (text) => + typeof text === "string" && + COPILOT_LOST_SESSION_MARKERS.some((marker) => text.includes(marker)), + ); +} + const CopilotSessionEvent = Schema.Struct({ sessionId: Schema.String, type: Schema.String, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 1997a23a2498..10dd5d53d3d9 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -407,6 +407,8 @@ export interface AcpAdapterV2Flavor { readonly reportsPerTurnPromptUsage?: boolean; /** Grok can retain task_already_running until process exit. */ readonly restartRuntimeAfterInterrupt?: boolean; + /** Prompt failures after which the agent process keeps failing until replaced. */ + readonly restartRuntimeAfterPromptError?: (error: unknown) => boolean; readonly restartRuntimeOnEveryInterrupt?: boolean; readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; readonly preserveRuntimeOnSettledInterrupt?: boolean; @@ -7840,12 +7842,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Result.isSuccess(error) && error.success._tag === "AcpRequestError" && error.success.code === -32800); + const failure = Cause.squash(cause); + if (!interrupted && flavor.restartRuntimeAfterPromptError?.(failure) === true) { + yield* Ref.set(runtimeRestartRequired, true); + } yield* finalizeTurn( context, interrupted ? "interrupted" : "failed", - flavor.promptFailure?.(Cause.squash(cause)) ?? + flavor.promptFailure?.(failure) ?? makeProviderFailure({ - cause: Cause.squash(cause), + cause: failure, class: "provider_error", }), ).pipe( From d1049c4435ac0d763dafbc09c20d089adacf4c10 Mon Sep 17 00:00:00 2001 From: Hieu Le <78847613+hieu-lee@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:35:08 +0100 Subject: [PATCH 094/158] perf(web): terminal snapshots reuse WASM memory views (#26717) Co-authored-by: Claude Opus 5.5 --- apps/web/src/terminal/ghostty/core.ts | 65 ++++++++++++------------ apps/web/src/terminal/ghostty/runtime.ts | 27 ++++++++-- 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index dcae81b4fb9f..b8ca5a624636 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -177,15 +177,19 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { } /** Bounds String.fromCodePoint arguments for cells with enormous combining-mark sequences. */ -export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { - if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true)); +export function ghosttyCellText( + codepointView: DataView, + graphemeLength: number, + byteOffset = 0, +): string { + if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(byteOffset, true)); const CHUNK_SIZE = 4_096; let text = ""; for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { const count = Math.min(CHUNK_SIZE, graphemeLength - start); const codes = new Array(count); for (let index = 0; index < count; index += 1) { - codes[index] = codepointView.getUint32((start + index) * 4, true); + codes[index] = codepointView.getUint32(byteOffset + (start + index) * 4, true); } text += String.fromCodePoint(...codes); } @@ -853,12 +857,12 @@ export class GhosttyTerminalCore { if (rowDirty) { this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); dirtyRows.add(rowIndex); - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); } rowIndex += 1; } - this.runtime.view(this.scratch, 4).setUint32(0, 0, true); + this.runtime.memoryDataView().setUint32(this.scratch, 0, true); this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); } @@ -1009,19 +1013,19 @@ export class GhosttyTerminalCore { "ghostty_render_state_row_get(raw)", this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), ); - const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); - this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + const rawRow = this.runtime.memoryDataView().getBigUint64(this.scratch, true); + this.runtime.memoryBytes()[this.scratch + 8] = 0; this.assertSuccess( "ghostty_row_get(wrap continuation)", this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), ); - const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; - this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + const isWrapContinuation = this.runtime.memoryBytes()[this.scratch + 8] !== 0; + this.runtime.memoryBytes()[this.scratch + 8] = 0; this.assertSuccess( "ghostty_row_get(wrap)", this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), ); - const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + const wrapsToNext = this.runtime.memoryBytes()[this.scratch + 8] !== 0; this.assertSuccess( "ghostty_render_state_row_get(cells)", @@ -1034,15 +1038,13 @@ export class GhosttyTerminalCore { ); const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle"); + const cellsNext = this.runtime.fn("ghostty_render_state_row_cells_next"); + const cellsGetMulti = this.runtime.fn("ghostty_render_state_row_cells_get_multi"); const cells: GhosttyCell[] = []; - while ( - cells.length < cols && - this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 - ) { - this.runtime.bytes(this.style, styleSize).fill(0); + while (cells.length < cols && cellsNext(cellsIterator) !== 0) { + this.runtime.memoryBytes().fill(0, this.style, this.style + styleSize); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); - const result = this.runtime.call( - "ghostty_render_state_row_cells_get_multi", + const result = cellsGetMulti( cellsIterator, 5, this.cellQuery + CELL_QUERY.keys, @@ -1081,8 +1083,7 @@ export class GhosttyTerminalCore { ) === GHOSTTY_SUCCESS ) { // Byte allocations need not be 4-byte aligned; use DataView instead of Uint32Array. - const codepointView = this.runtime.view(this.graphemes, bufferSize); - text = ghosttyCellText(codepointView, graphemeLength); + text = ghosttyCellText(this.runtime.memoryDataView(), graphemeLength, this.graphemes); } } let wide = 0; @@ -1097,13 +1098,13 @@ export class GhosttyTerminalCore { this.scratch, ), ); - const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); - this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); + const rawCell = this.runtime.memoryDataView().getBigUint64(this.scratch, true); + this.runtime.memoryDataView().setUint32(this.scratch + 8, 0, true); this.assertSuccess( "ghostty_cell_get(wide)", this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), ); - wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); + wide = this.runtime.memoryDataView().getUint32(this.scratch + 8, true); } // Read the style after allocation and ABI calls, which can grow WASM memory. const styleView = this.runtime.memoryDataView(); @@ -1228,34 +1229,34 @@ export class GhosttyTerminalCore { } private getU16(data: number): number { - this.runtime.bytes(this.scratch, 2).fill(0); + this.runtime.memoryDataView().setUint16(this.scratch, 0, true); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.view(this.scratch, 2).getUint16(0, true); + return this.runtime.memoryDataView().getUint16(this.scratch, true); } private getU32(data: number): number { - this.runtime.bytes(this.scratch, 4).fill(0); + this.runtime.memoryDataView().setUint32(this.scratch, 0, true); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.view(this.scratch, 4).getUint32(0, true); + return this.runtime.memoryDataView().getUint32(this.scratch, true); } private getBool(data: number): boolean { - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.bytes(this.scratch, 1)[0] !== 0; + return this.runtime.memoryBytes()[this.scratch] !== 0; } private getColor(data: number, fallback: GhosttyColor): GhosttyColor { - this.runtime.bytes(this.scratch, 3).fill(0); + this.runtime.memoryBytes().fill(0, this.scratch, this.scratch + 3); const result = this.runtime.call( "ghostty_render_state_get", this.renderState, @@ -1266,15 +1267,15 @@ export class GhosttyTerminalCore { } private getRowBool(iterator: number, data: number): boolean { - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; return ( this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === - GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + GHOSTTY_SUCCESS && this.runtime.memoryBytes()[this.scratch] !== 0 ); } private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { - this.runtime.bytes(this.scratch, 3).fill(0); + this.runtime.memoryBytes().fill(0, this.scratch, this.scratch + 3); const result = this.runtime.call( "ghostty_render_state_row_cells_get", iterator, diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index 22c9c13e5072..762fb0f6ef13 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -24,6 +24,7 @@ export class GhosttyRuntime { readonly layouts: TypeLayouts; private readonly exports: WebAssembly.Exports; private memoryView: DataView; + private memoryU8: Uint8Array; private readonly ptyWriters = new Map void>(); private nextPtyWriterId = 1; private writePtyFunctionIndex = 0; @@ -36,6 +37,7 @@ export class GhosttyRuntime { } this.memory = memory; this.memoryView = new DataView(memory.buffer); + this.memoryU8 = new Uint8Array(memory.buffer); const jsonPointer = this.call("ghostty_type_json"); const bytes = new Uint8Array(memory.buffer); let end = jsonPointer; @@ -68,11 +70,15 @@ export class GhosttyRuntime { } call(name: string, ...args: Array): number { + return this.fn(name)(...args); + } + + fn(name: string): WasmFunction { const fn = this.exports[name]; if (typeof fn !== "function") { throw new Error(`libghostty-vt export is unavailable: ${name}`); } - return (fn as WasmFunction)(...args); + return fn as WasmFunction; } layout(name: string): TypeLayout { @@ -126,18 +132,29 @@ export class GhosttyRuntime { } view(pointer: number, size?: number): DataView { - return new DataView(this.memory.buffer, pointer, size); + return new DataView(this.memoryDataView().buffer, pointer, size); } bytes(pointer: number, size: number): Uint8Array { - return new Uint8Array(this.memory.buffer, pointer, size); + return new Uint8Array(this.memoryDataView().buffer, pointer, size); } memoryDataView(): DataView { - if (this.memoryView.buffer !== this.memory.buffer) { + this.refreshMemoryViews(); + return this.memoryView; + } + + memoryBytes(): Uint8Array { + this.refreshMemoryViews(); + return this.memoryU8; + } + + private refreshMemoryViews(): void { + // Growing memory detaches the old buffer. Reading `memory.buffer` costs far more than this check. + if (this.memoryU8.byteLength === 0) { this.memoryView = new DataView(this.memory.buffer); + this.memoryU8 = new Uint8Array(this.memory.buffer); } - return this.memoryView; } setField(pointer: number, structName: string, fieldName: string, value: number): void { From 17c837c52253b5cb60c1868ee92b923dcb4d9279 Mon Sep 17 00:00:00 2001 From: Radu-Stefan Popa <150839549+Radu028@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:35:24 +0200 Subject: [PATCH 095/158] fix(acp): Plan mode switches Copilot into its native plan mode (#26951) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/scripts/acp-mock-agent.ts | 53 ++-- .../Adapters/AcpAdapterV2.test.ts | 278 ++++++++++-------- packages/provider-acp/src/server/adapter.ts | 14 +- 3 files changed, 195 insertions(+), 150 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 667f9eb677ba..ae07db783c92 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -18,6 +18,11 @@ import { beginAcpMockPrompt } from "./acpMockCancellationState.ts"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1"; +// GitHub Copilot CLI advertises URL-shaped session mode ids. +const copilotModesProfile = process.env.T3_ACP_COPILOT_MODES === "1"; +const copilotModeId = (name: string) => + `https://agentclientprotocol.com/protocol/session-modes#${name}`; +const modeOptionCategory = process.env.T3_ACP_MODE_OPTION_CATEGORY ?? "mode"; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -74,7 +79,11 @@ const permissionOptionIds = { }; const sessionId = "mock-session-1"; -let currentModeId = antigravityProfile ? "default" : "ask"; +let currentModeId = antigravityProfile + ? "default" + : copilotModesProfile + ? copilotModeId("agent") + : "ask"; let currentModelId = antigravityProfile ? "gemini-test-low" : "default"; let parameterizedModelPicker = false; let currentReasoning = "medium"; @@ -263,7 +272,7 @@ function configOptions(): ReadonlyArray { { configId: "mode", name: "Mode", - category: "mode", + category: modeOptionCategory, type: "select" as const, currentValue: currentModeId, options: availableModes.map((mode) => ({ @@ -328,23 +337,29 @@ const availableModes: ReadonlyArray = antigravityProfile { id: "auto_edit", name: "Auto edit" }, { id: "yolo", name: "YOLO" }, ] - : [ - { - id: "ask", - name: "Ask", - description: "Request permission before making any changes", - }, - { - id: "architect", - name: "Architect", - description: "Design and plan software systems without implementation", - }, - { - id: "code", - name: "Code", - description: "Write and modify code with full tool access", - }, - ]; + : copilotModesProfile + ? [ + { id: copilotModeId("agent"), name: "Agent" }, + { id: copilotModeId("plan"), name: "Plan" }, + { id: copilotModeId("autopilot"), name: "Autopilot" }, + ] + : [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, + ]; function modeState(): AcpCompat.SessionModeState { return { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 73cb274501c5..bb84c1a2c749 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1712,136 +1712,162 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("refreshes ACP prompt instructions when the interaction mode changes", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.unbounded(); - const instanceId = ProviderInstanceId.make("acp-test-instruction-transitions"); - const threadId = ThreadId.make("thread-acp-instruction-transitions"); - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("environment-acp-instruction-transitions"), - threadId, - providerSessionId: "mcp-session-acp-instruction-transitions", - providerInstanceId: instanceId, - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer instruction-transition-token", - browserToolsAvailable: false, - }); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - protocolEvents, - }), - }, - selfInvocation, - }); - const policy = (interactionMode: "default" | "plan") => - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode, - cwd: process.cwd(), + it.live.each([ + { modeIds: "plain", environment: {}, planModeId: "architect", buildModeId: "ask" }, + { + modeIds: "Copilot URL", + environment: { T3_ACP_COPILOT_MODES: "1" }, + planModeId: "https://agentclientprotocol.com/protocol/session-modes#plan", + buildModeId: "https://agentclientprotocol.com/protocol/session-modes#agent", + }, + { + // Without a `mode` category, T3 derives no mode state and only the config option switches. + modeIds: "Copilot URL collaboration_mode", + environment: { T3_ACP_COPILOT_MODES: "1", T3_ACP_MODE_OPTION_CATEGORY: "collaboration_mode" }, + planModeId: "https://agentclientprotocol.com/protocol/session-modes#plan", + buildModeId: "https://agentclientprotocol.com/protocol/session-modes#agent", + }, + ])( + "refreshes ACP prompt instructions and native mode when the interaction mode changes ($modeIds mode ids)", + ({ environment, planModeId, buildModeId }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.unbounded(); + const instanceId = ProviderInstanceId.make("acp-test-instruction-transitions"); + const threadId = ThreadId.make("thread-acp-instruction-transitions"); + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("environment-acp-instruction-transitions"), + threadId, + providerSessionId: "mcp-session-acp-instruction-transitions", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer instruction-transition-token", + browserToolsAvailable: false, }); - const defaultPolicy = policy("default"); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-instruction-transitions"), - modelSelection, - runtimePolicy: defaultPolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy: defaultPolicy, - }); - const now = yield* DateTime.now; - const runTurn = Effect.fnUntraced(function* ( - ordinal: number, - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, - messageText: string, - ) { - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now, - ordinal, - messageText, - }), + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + environment, + }), + }, + selfInvocation, + }); + const policy = (interactionMode: "default" | "plan") => + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode, + cwd: process.cwd(), + }); + const defaultPolicy = policy("default"); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-instruction-transitions"), + modelSelection, + runtimePolicy: defaultPolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: defaultPolicy, + }); + const now = yield* DateTime.now; + const runTurn = Effect.fnUntraced(function* ( + ordinal: number, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + messageText: string, + ) { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal, + messageText, + }), + ); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runDrain, + ); + const methods: Array = []; + const modeSets: Array = []; + while (true) { + const event = yield* Queue.take(protocolEvents); + if (event.direction !== "outgoing") continue; + const method = rawProtocolMethod(event); + if (method === undefined) continue; + methods.push(method); + if ( + method === "session/set_config_option" && + rawProtocolRequestParam(event, "configId") === "mode" + ) { + modeSets.push(rawProtocolRequestParam(event, "value")); + } + if (method === "session/prompt") { + const promptBlocks = rawProtocolPromptTextBlocks(event); + return { + methods, + modeSets, + prompt: promptBlocks.map((block) => block.text).join("\n"), + textBlockCount: promptBlocks.length, + }; + } + } + }); + + const command = yield* runTurn(0, defaultPolicy, "/compact"); + assert.isTrue(command.prompt.startsWith("/compact")); + assert.notInclude(command.prompt, ""); + assert.equal(command.textBlockCount, 1); + const whitespaceCommand = yield* runTurn(1, defaultPolicy, " /repro-skill"); + assert.equal(whitespaceCommand.prompt, " /repro-skill"); + assert.equal(whitespaceCommand.textBlockCount, 1); + assert.notInclude(whitespaceCommand.prompt, ""); + const firstDefault = yield* runTurn(2, defaultPolicy, "First default request."); + assert.equal(firstDefault.textBlockCount, 2); + assert.include(firstDefault.prompt, "T3 Code interaction mode: Default"); + assert.include(firstDefault.prompt, "## T3 Code browser"); + assert.include(firstDefault.prompt, "T3 Code orchestration"); + assert.notInclude( + firstDefault.methods, + "session/set_config_option", + "Build should preserve the agent's advertised mode default", ); - yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runDrain, + assert.include( + (yield* runTurn(3, defaultPolicy, "Second default request.")).prompt, + "Second default request.", ); - const methods: Array = []; - while (true) { - const event = yield* Queue.take(protocolEvents); - if (event.direction !== "outgoing") continue; - const method = rawProtocolMethod(event); - if (method === undefined) continue; - methods.push(method); - if (method === "session/prompt") { - const promptBlocks = rawProtocolPromptTextBlocks(event); - return { - methods, - prompt: promptBlocks.map((block) => block.text).join("\n"), - textBlockCount: promptBlocks.length, - }; - } - } - }); - const command = yield* runTurn(0, defaultPolicy, "/compact"); - assert.isTrue(command.prompt.startsWith("/compact")); - assert.notInclude(command.prompt, ""); - assert.equal(command.textBlockCount, 1); - const whitespaceCommand = yield* runTurn(1, defaultPolicy, " /repro-skill"); - assert.equal(whitespaceCommand.prompt, " /repro-skill"); - assert.equal(whitespaceCommand.textBlockCount, 1); - assert.notInclude(whitespaceCommand.prompt, ""); - const firstDefault = yield* runTurn(2, defaultPolicy, "First default request."); - assert.equal(firstDefault.textBlockCount, 2); - assert.include(firstDefault.prompt, "T3 Code interaction mode: Default"); - assert.include(firstDefault.prompt, "## T3 Code browser"); - assert.include(firstDefault.prompt, "T3 Code orchestration"); - assert.notInclude( - firstDefault.methods, - "session/set_config_option", - "Build should preserve the agent's advertised mode default", - ); - assert.include( - (yield* runTurn(3, defaultPolicy, "Second default request.")).prompt, - "Second default request.", - ); - - const planPolicy = policy("plan"); - const firstPlan = yield* runTurn(4, planPolicy, "Plan this change."); - assert.include(firstPlan.prompt, "T3 Code interaction mode: Plan"); - assert.include(firstPlan.methods, "session/set_config_option"); - assert.include( - (yield* runTurn(5, planPolicy, "Continue planning.")).prompt, - "Continue planning.", - ); - const restoredBuild = yield* runTurn(6, defaultPolicy, "Implement the change."); - assert.include(restoredBuild.prompt, "T3 Code interaction mode: Default"); - assert.include( - restoredBuild.methods, - "session/set_config_option", - "Build should restore the native mode that T3 temporarily replaced for Plan", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + const planPolicy = policy("plan"); + const firstPlan = yield* runTurn(4, planPolicy, "Plan this change."); + assert.include(firstPlan.prompt, "T3 Code interaction mode: Plan"); + assert.deepEqual(firstPlan.modeSets, [planModeId]); + assert.include( + (yield* runTurn(5, planPolicy, "Continue planning.")).prompt, + "Continue planning.", + ); + const restoredBuild = yield* runTurn(6, defaultPolicy, "Implement the change."); + assert.include(restoredBuild.prompt, "T3 Code interaction mode: Default"); + assert.deepEqual( + restoredBuild.modeSets, + [buildModeId], + "Build should restore the native mode that T3 temporarily replaced for Plan", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); it.live( diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 10dd5d53d3d9..bc34b9fd65ff 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1047,6 +1047,12 @@ function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): D return status === "completed" || status === "failed" || status === "interrupted" ? now : null; } +// Copilot advertises URL ids such as `https://agentclientprotocol.com/protocol/session-modes#plan`. +function isAcpPlanModeId(id: string): boolean { + const name = id.slice(id.lastIndexOf("#") + 1); + return name === "plan" || name === "architect"; +} + function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, @@ -6960,8 +6966,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( })), }); } - const planMode = effectiveModeState?.availableModes.find( - (mode) => mode.id === "plan" || mode.id === "architect", + const planMode = effectiveModeState?.availableModes.find((mode) => + isAcpPlanModeId(mode.id), ); if (planMode !== undefined && effectiveModeState?.currentModeId !== planMode.id) { yield* runtime.setMode(planMode.id); @@ -6970,9 +6976,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const choices = option.options.flatMap((entry) => "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value), ); - const requested = choices.find( - (choice) => choice === "plan" || choice === "architect", - ); + const requested = choices.find(isAcpPlanModeId); if (requested !== undefined && option.currentValue !== requested) { yield* runtime.setConfigOption(option.id, requested); } From 22ee8a43e57bdae2b72d2b61826f4d7f17d4baa5 Mon Sep 17 00:00:00 2001 From: Riccardo Palleschi Date: Sun, 1 Nov 2026 03:35:39 +0100 Subject: [PATCH 096/158] fix(cursor): ignore a stale Keychain login when Cursor is not installed (#27143) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/server/src/usage/UsageService.test.ts | 31 +++++++++++++++++++ .../src/server/CursorUsageAccounts.ts | 7 ++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9600f0a45adf..07a78dc6d50d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1462,6 +1462,37 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.effect("ignores a stale macOS Keychain login when Cursor is not installed", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const cursor = makeFakeCursor(); + cursor.state.error = "Sign in to Cursor again to read account usage."; + const readCursorSource = Effect.gen(function* () { + const service = yield* makeWithCursor(cursor.read); + const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + return cursorSource(summary); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-keychain-uninstalled", + home, + settings: { ...settings, cursorKeychainUsageEnabled: true }, + platform: "darwin", + environment: {}, + }), + ), + ); + + assert.isUndefined(yield* readCursorSource); + assert.lengthOf(cursor.state.calls, 0); + + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(home, ".cursor"))); + const installed = yield* readCursorSource; + assert.strictEqual(installed?.message, "Sign in to Cursor again to read account usage."); + assert.lengthOf(cursor.state.calls, 1); + }).pipe(Effect.scoped), + ); + it.live("ignores stale Cursor file logins when the active credential store differs", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 8f6b08670ea7..07fa215f2eeb 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -186,10 +186,9 @@ const make = Effect.gen(function* () { retentionStartMs: number, awaitRefresh: boolean, ) { - if ( - typeof credential === "string" && - !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) - ) { + // A Keychain login outlives an uninstalled Cursor, so it also needs Cursor's local directory. + const localLogin = typeof credential === "string" ? credential : path.dirname(authPath); + if (!(yield* fileSystem.exists(localLogin).pipe(Effect.orElseSucceed(() => true)))) { return null; } yield* ensureLoaded; From d64888fbc403b52081761140d045542acc0391f2 Mon Sep 17 00:00:00 2001 From: Nik Date: Sun, 1 Nov 2026 03:35:54 +0100 Subject: [PATCH 097/158] fix(web): Escape while recording a shortcut no longer closes Settings (#27151) Co-authored-by: Nik <5053591+treiber@users.noreply.github.com> Co-authored-by: maria-rcks --- .../KeybindingsSettings.browser.test.tsx | 91 +++++++++++++++++-- .../settings/KeybindingsSettings.tsx | 27 +++++- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx b/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx index c65d2cdfbd90..fb17e32e163d 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.browser.test.tsx @@ -1,9 +1,20 @@ import { RegistryProvider } from "@effect/atom-react"; -import { userEvent } from "vite-plus/test/browser"; -import { expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent, type Locator } from "vite-plus/test/browser"; import { render } from "../../../test/render"; +vi.mock(import("../../state/session"), async (original) => ({ + ...(await original()), + useEnvironmentScope: () => true, + useEnvironmentsWithScope: ((environments: ReadonlyArray<{ environmentId: string }>) => + new Set(environments.map(({ environmentId }) => environmentId))) as never, + readEnvironmentScope: () => true, +})); +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => vi.fn()) as never, +})); vi.mock(import("./SettingsScopeContext"), async (original) => ({ ...(await original()), useSettingsScope: (() => { @@ -22,16 +33,78 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({ }; }) as never, })); -vi.mock(import("../../state/session"), async (original) => ({ - ...(await original()), - useEnvironmentScope: () => true, - useEnvironmentsWithScope: ((environments: ReadonlyArray<{ environmentId: string }>) => - new Set(environments.map((environment) => environment.environmentId))) as never, - readEnvironmentScope: () => true, -})); +import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { KeybindingsSettingsPanel } from "./KeybindingsSettings"; +// The modal dialog's inert layer swallows pointer events in this harness, so drive the DOM. +async function activate(locator: Locator, action: "click" | "focus") { + await expect.element(locator).toBeVisible(); + (locator.element() as HTMLElement)[action](); +} + +const existingField = page.getByRole("textbox", { name: "Keybinding for Sidebar: Toggle" }); +const existingPill = page.getByRole("button", { name: /^Edit shortcut for Sidebar: Toggle:/ }); +const newField = page.getByRole("textbox", { name: "Keybinding for new keybinding" }); + +describe("Esc in a shortcut recorder", () => { + it.each<{ + name: string; + field: Locator; + open: () => Promise; + expectReset: () => Promise; + }>([ + { + name: "existing binding", + field: existingField, + open: () => activate(existingPill, "click"), + expectReset: async () => { + await expect.element(existingField).not.toBeInTheDocument(); + await expect.element(existingPill).toHaveFocus(); + await expect.element(page.getByRole("button", { name: "Save" })).not.toBeInTheDocument(); + }, + }, + { + name: "new binding", + field: newField, + open: async () => { + await activate(page.getByRole("button", { name: "Add keybinding" }), "click"); + await activate(newField, "focus"); + }, + expectReset: async () => { + await expect.element(newField).toHaveValue(""); + await expect.element(newField).toHaveAttribute("placeholder", "Unassigned"); + }, + }, + ])("leaves the $name field without closing Settings", async ({ field, open, expectReset }) => { + const onOpenChange = vi.fn(); + await using _screen = await render( + + + Settings + + + , + ); + + await open(); + await userEvent.keyboard("{Control>}k{/Control}"); + await expect.element(field).not.toHaveValue(""); + await userEvent.tab(); + await activate(field, "focus"); + await expect.element(field).toHaveAttribute("placeholder", "Press shortcut"); + + await userEvent.keyboard("{Escape}"); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(document.activeElement?.matches("[data-keybinding-capture]")).toBe(false); + await expectReset(); + + await userEvent.keyboard("{Escape}"); + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); +}); + it("re-enables Save when a When editor closes on an expression it could not parse", async () => { await using screen = await render( diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index f6298d778073..cb00bca7e9ca 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -20,6 +20,7 @@ import { useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -717,6 +718,7 @@ function useKeybindingRowEditor({ onSave: (input: ServerUpsertKeybindingInput) => void; }) { const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); + const pillRef = useRef(null); const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; const whenDraftExpression = whenAstToExpression(whenDraft); const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; @@ -739,7 +741,13 @@ function useKeybindingRowEditor({ if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { - setDraft({ keyDraft: row.key, isRecording: false }); + // An unhandled Escape closes the settings page. + event.stopPropagation(); + const field = event.currentTarget; + flushSync(() => setDraft({ keyDraft: row.key, isRecording: false })); + // The field may unmount for the pill; focus would otherwise drop to the page. + if (pillRef.current) pillRef.current.focus(); + else field.blur(); return; } const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, { @@ -760,6 +768,7 @@ function useKeybindingRowEditor({ setDraft, save, captureKeybinding, + pillRef, }; } @@ -790,8 +799,16 @@ function KeybindingKeyControl({ isSaving: boolean; pillClassName?: string | undefined; }) { - const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } = - editor; + const { + keyDraft, + isRecording, + isDirty, + isWhenDraftValid, + setDraft, + save, + captureKeybinding, + pillRef, + } = editor; const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; return ( @@ -807,6 +824,7 @@ function KeybindingKeyControl({ ) : null} {showPill ? (
) : ( diff --git a/apps/web/src/components/files/FileExplorerPane.browser.test.tsx b/apps/web/src/components/files/FileExplorerPane.browser.test.tsx new file mode 100644 index 000000000000..58f1064beed7 --- /dev/null +++ b/apps/web/src/components/files/FileExplorerPane.browser.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import { FileExplorerPane } from "./FileExplorerPane"; + +const STORAGE_KEY = "test:file-explorer-default"; + +describe("FileExplorerPane", () => { + it.each([ + { view: "Changes", defaultWidth: 256, defaultWidthFraction: 0.4, minWidth: 160, expected: 168 }, + { view: "PR Code", defaultWidth: 320, defaultWidthFraction: 0.4, minWidth: 192, expected: 192 }, + { + view: "Files", + defaultWidth: 256, + minWidth: 160, + expected: 164, + }, + ])( + "preserves the $view initial width in a 420px row", + async ({ expected, view: _, ...sizing }) => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This layout has no navigation or links. + await using screen = await render( +
+ + File tree + +
, + { router: false }, + ); + await expect + .element(screen.getByRole("separator")) + .toHaveAttribute("aria-valuenow", String(expected)); + expect(screen.container.querySelector("aside")!.getBoundingClientRect().width).toBe( + expected, + ); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }, + ); +}); diff --git a/apps/web/src/components/files/FileExplorerPane.tsx b/apps/web/src/components/files/FileExplorerPane.tsx index c13a814f6305..f88a9fdc8bb4 100644 --- a/apps/web/src/components/files/FileExplorerPane.tsx +++ b/apps/web/src/components/files/FileExplorerPane.tsx @@ -9,18 +9,33 @@ const MIN_WIDTH = 160; const MAX_WIDTH = 560; const FILE_CONTENT_MIN_WIDTH = 256; +/** A persisted, resizable file-tree column; fills the row when resizing is disabled. + * Callers can retain a responsive initial width via defaultWidthFraction (a fraction of the row), + * and use a separate saved width, minimum and resize label. A chosen width overrides the default. + */ export function FileExplorerPane({ resizable, children, + storageKey = "t3code.fileExplorerWidth", + defaultWidth = 256, + defaultWidthFraction, + minWidth = MIN_WIDTH, + resizeLabel = "Resize file explorer", }: { resizable: boolean; children: ReactNode; + storageKey?: string; + defaultWidth?: number; + defaultWidthFraction?: number; + minWidth?: number; + resizeLabel?: string; }) { const [maxWidth, setMaxWidth] = useState(MAX_WIDTH); + const [responsiveDefaultWidth, setResponsiveDefaultWidth] = useState(defaultWidth); const { width, setWidth, handlers } = useResizableWidth({ - storageKey: "t3code.fileExplorerWidth", - defaultWidth: 256, - minWidth: MIN_WIDTH, + storageKey, + defaultWidth: responsiveDefaultWidth, + minWidth, maxWidth, edge: "left", }); @@ -30,10 +45,19 @@ export function FileExplorerPane({ ref={(host) => { const row = host?.parentElement; if (!row || !resizable) return; - const measure = () => - setMaxWidth( - Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, row.clientWidth - FILE_CONTENT_MIN_WIDTH)), + const measure = () => { + const initialWidth = Math.max( + minWidth, + Math.min(defaultWidth, row.clientWidth * (defaultWidthFraction ?? 1)), ); + // A responsive default must not shrink to make room for the content reserve. + const availableWidth = Math.max( + row.clientWidth - FILE_CONTENT_MIN_WIDTH, + defaultWidthFraction === undefined ? minWidth : initialWidth, + ); + setMaxWidth(Math.max(minWidth, Math.min(MAX_WIDTH, availableWidth))); + setResponsiveDefaultWidth(initialWidth); + }; measure(); return observeResize(row, measure); }} @@ -54,10 +78,10 @@ export function FileExplorerPane({ {resizable ? ( { if (event.altKey || event.ctrlKey || event.metaKey) return; @@ -70,7 +94,7 @@ export function FileExplorerPane({ nextWidth = width - 16; break; case "Home": - nextWidth = MIN_WIDTH; + nextWidth = minWidth; break; case "End": nextWidth = maxWidth; diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 94f4ccda8255..86e2e27b9836 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -81,6 +81,7 @@ import { DiffPanelLoadingState } from "../DiffPanelShell"; import { useDiffWorkerPool } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; +import { FileExplorerPane } from "../files/FileExplorerPane"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; import { useHideTestFiles } from "../diffs/useHideTestFiles"; import { diffFileTreeEntries, orderDiffFilesLikeTree } from "../diffs/diffFileTree.logic"; @@ -1515,7 +1516,14 @@ function PullRequestCodeTab({ {fileTreeOpen ? ( - + ) : null} {unstructured} diff --git a/apps/web/src/hooks/useResizableWidth.browser.test.tsx b/apps/web/src/hooks/useResizableWidth.browser.test.tsx new file mode 100644 index 000000000000..a34c985f8c12 --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.browser.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; + +const STORAGE_KEY = "test:responsive-panel-width"; + +function Panel({ defaultWidth }: { defaultWidth: number }) { + const { width, setWidth, handlers } = useResizableWidth({ + storageKey: STORAGE_KEY, + defaultWidth, + minWidth: 160, + maxWidth: 560, + edge: "left", + }); + return ( + <> + +
+ + ); +} + +describe("useResizableWidth", () => { + it("follows a responsive default until the user saves a width", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + expect(screen.container.textContent).toBe("320"); + await screen.rerender(); + expect(screen.container.textContent).toBe("200"); + + await act(() => screen.container.querySelector("button")!.click()); + await screen.rerender(); + expect(screen.container.textContent).toBe("350"); + + await screen.unmount(); + await using restored = await render(, { router: false }); + expect(restored.container.textContent).toBe("350"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); + + it("keeps the default responsive after a stationary click and persists an actual drag", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + await screen.getByRole("separator").click(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + await screen.rerender(); + expect(screen.container.textContent).toBe("256"); + + const handle = screen.container.querySelector('[role="separator"]')!; + // Synthetic drag events have no active browser pointer to capture. + using _capture = vi.spyOn(handle, "setPointerCapture").mockImplementation(() => {}); + const pointer = { bubbles: true, pointerId: 1, button: 0 }; + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + }); + await screen.rerender(); + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 500 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(screen.container.style.getPropertyValue(RESIZABLE_WIDTH_PROPERTY)).toBe("280px"); + + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 450 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBe("330"); + await screen.rerender(); + expect(screen.container.textContent).toBe("330"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 50fdba38a9fc..dc5fe8569d03 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useState } fro import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; import { useResizeDrag } from "./useResizeDrag"; +import { useStableCallback } from "./useStableCallback"; const WidthSchema = Schema.Finite; @@ -26,7 +27,7 @@ export interface ResizableWidthHandlers { readonly onLostPointerCapture: (event: ReactPointerEvent) => void; } -/** Persist on drag end to avoid a storage write on every animation frame. */ +/** Uses the current default until a width is chosen; persists on drag end, not every frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; readonly setWidth: (width: number) => void; @@ -39,14 +40,14 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return Math.max(minWidth, Math.min(maxWidth, value)); }; - const readWidth = () => { - if (typeof window === "undefined") return defaultWidth; + const readWidth = (): number | null => { + if (typeof window === "undefined") return null; try { const stored = getLocalStorageItem(storageKey, WidthSchema); - return clamp(stored ?? defaultWidth); + return stored === null ? null : clamp(stored); } catch (error) { console.error("Could not read persisted panel width.", error); - return defaultWidth; + return null; } }; const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); @@ -54,7 +55,10 @@ export function useResizableWidth(options: UseResizableWidthOptions): { setWidthState({ storageKey, width: readWidth() }); } - const clampedWidth = clamp(widthState.width); + const clampedWidth = clamp(widthState.width ?? defaultWidth); + const restoreWidth = useStableCallback((host: HTMLElement | null) => { + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${clampedWidth}px`); + }); const commitWidth = (nextStorageKey: string, nextWidth: number) => { // Resizing remains usable for this session when browser storage is unavailable. @@ -82,8 +86,9 @@ export function useResizableWidth(options: UseResizableWidthOptions): { host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, - finish(finalWidth) { - commitWidth(startStorageKey, finalWidth); + finish(finalWidth, moved) { + if (moved) commitWidth(startStorageKey, finalWidth); + else restoreWidth(host); }, cleanup(committed) { host?.style.removeProperty("transition-property"); From 2f777bdd497aecc9bb7221d106d338f1a8784d6f Mon Sep 17 00:00:00 2001 From: Yuruzuu <108398860+Yuruzuu@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:48:30 +0800 Subject: [PATCH 147/158] perf(web): main thread updates no longer re-render an open side chat (#28264) --- apps/web/src/components/ChatView.tsx | 10 ++++++---- .../src/components/chat/RightPanelSurfaceContent.tsx | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 99fe420431c1..ea477b914479 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3177,6 +3177,11 @@ export default function ChatView(props: ChatViewProps) { (folders: Parameters[0]) => composerRef.current?.addDroppedFolders(folders), ); + // Above the early return, where React Compiler can cache it for the side chat pane. + const sideChatParentPanel = { + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }; if (!activeThread) { return embedded ? null : ; @@ -3196,10 +3201,7 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} - sideChatParentPanel={{ - openToolDiff: onOpenToolDiffStable, - runShellCommand: runSideChatShellCommandStable, - }} + sideChatParentPanel={sideChatParentPanel} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 6ca50c824cb4..b703e5d05da9 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -79,7 +79,7 @@ export function RightPanelSurfaceContent(props: { readonly files: RightPanelFileProps; readonly sideChatParentPanel: SideChatParentPanel; }) { - const { surface, threadRef, visible } = props; + const { surface, threadRef, visible, onCloseSurface } = props; if (surface === null) return null; switch (surface.kind) { case "preview": @@ -176,7 +176,7 @@ export function RightPanelSurfaceContent(props: { parentRef={threadRef} threadId={surface.threadId} parentPanel={props.sideChatParentPanel} - onClose={() => props.onCloseSurface(surface)} + onClose={() => onCloseSurface(surface)} /> ); case "device": From 6d25fa10bda85402d1c564d6baa1c33fec6f155c Mon Sep 17 00:00:00 2001 From: Yuruzuu <108398860+Yuruzuu@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:48:44 +0800 Subject: [PATCH 148/158] perf(server): skip unused parent data when forking threads (#28265) --- .../src/orchestration-v2/Orchestrator.ts | 3 +- .../ThreadFork.execution.test.ts | 75 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d1838e7d10f7..35e9a78c4cd5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4261,11 +4261,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const sourceProjection = yield* projectionStore .getThreadRecords(command.sourceThreadId, [ "runs", - "checkpoints", + ...(command.sourcePoint.type === "checkpoint" ? (["checkpoints"] as const) : []), "providerThreads", "providerTurns", "attempts", - "contextTransfers", ]) .pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 71d40332f2d7..96c7263173f3 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -1,6 +1,8 @@ import { assert, it } from "@effect/vitest"; import { CheckpointId, + CheckpointRef, + CheckpointScopeId, CommandId, EventId, MessageId, @@ -17,6 +19,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Statement from "effect/sql/Statement"; import * as TestClock from "effect/testing/TestClock"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -865,6 +868,61 @@ const askSideChat = (threadId: ThreadId, commandId: string) => }); }); +it.effect("resolves an explicit checkpoint fork to its source run", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const parentId = ThreadId.make("checkpoint-fork-parent"); + const childId = ThreadId.make("checkpoint-fork-child"); + const runId = RunId.make("checkpoint-fork-run"); + const checkpointId = CheckpointId.make("checkpoint-fork-checkpoint"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId, + ordinal: 1, + status: "completed", + text: "Work", + }); + yield* eventSink.write({ + events: [ + { + id: EventId.make("checkpoint-fork-captured"), + type: "checkpoint.captured", + threadId: parentId, + occurredAt: now, + payload: { + id: checkpointId, + threadId: parentId, + scopeId: CheckpointScopeId.make("checkpoint-fork-scope"), + runId, + nodeId: NodeId.make("checkpoint-fork-node"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: 1, + ref: CheckpointRef.make("refs/t3/checkpoint-fork/1"), + status: "ready", + files: [], + capturedAt: now, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("checkpoint-fork"), + sourceThreadId: parentId, + targetThreadId: childId, + sourcePoint: { type: "checkpoint", checkpointId }, + createdBy: "user", + creationSource: "mcp", + }); + const child = yield* orchestrator.getThreadShell(childId); + assert.deepEqual(child?.forkedFrom, { type: "run", threadId: parentId, runId }); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + it.effect("a side chat of an idle parent still starts with the framing note", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -880,7 +938,22 @@ it.effect("a side chat of an idle parent still starts with the framing note", () stable: true, }); - yield* forkSideChat("side-chat-idle-fork", parentId, sideChatId); + const reads: Array = []; + const recordReads: Statement.Transformer = (statement) => { + const [query] = statement.compile(); + if (/^\s*SELECT/i.test(query)) reads.push(query); + return Effect.succeed(statement); + }; + yield* forkSideChat("side-chat-idle-fork", parentId, sideChatId).pipe( + Effect.provideService(Statement.CurrentTransformer, recordReads), + ); + assert.isEmpty( + reads.filter( + (query) => + query.includes("FROM orchestration_v2_projection_checkpoints") || + query.includes("FROM orchestration_v2_projection_context_transfers"), + ), + ); yield* askSideChat(sideChatId, "side-chat-idle-first-message"); const started = yield* orchestrator.getThreadProjection(sideChatId); From 13c3c2e43b6b98929812ddd559c0f2194482f321 Mon Sep 17 00:00:00 2001 From: Yuruzuu <108398860+Yuruzuu@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:49:00 +0800 Subject: [PATCH 149/158] perf(server): stop summarizing side chat context at its cap (#28266) --- .../ContextHandoffService.test.ts | 65 +++++++++++++------ .../orchestration-v2/ContextHandoffService.ts | 26 ++++---- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index d9005e6b6402..b5109cb99d93 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + ContextTransferId, MessageId, ProjectId, ProviderInstanceId, @@ -160,34 +161,56 @@ it.layer(layerTest)("ContextHandoffService legacy import", (it) => { ); }); -it("caps the side chat context block, keeping the newest parent items", () => { - const parentThreadId = ThreadId.make("thread:side-chat-parent"); - const digest = Array.from({ length: 60 }, (_, index) => - ContextHandoffService.summarizeDeltaItem( - importedItem({ +it.effect("caps the side chat context block without summarizing discarded parent history", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:side-chat-parent"); + let summarizedItems = 0; + const parentItems = Array.from({ length: 60 }, (_, index) => ({ + ...importedItem({ role: index % 2 === 0 ? "user" : "assistant", id: `parent-${index}`, text: `item-${index} ${"x".repeat(300)}`, ordinal: index, }), - )!, - ); - const context = ContextHandoffService.makeSideChatContext({ parentThreadId, digest }); + get text() { + summarizedItems++; + return `item-${index} ${"x".repeat(300)}`; + }, + })); + const service = yield* ContextHandoffService.ContextHandoffServiceV2; + const handoff = yield* service.prepareSideChatContext({ + parentThreadId, + threadId: ThreadId.make("thread:side-chat"), + targetRunId: RunId.make("run:side-chat"), + transferId: ContextTransferId.make("transfer:side-chat"), + toProviderThreadId: ProviderThreadId.make("provider-thread:side-chat"), + fromProviderInstanceId: ProviderInstanceId.make("codex"), + toProviderInstanceId: ProviderInstanceId.make("codex"), + coveredRunOrdinals: { from: 1, to: 1 }, + parentItems, + createdAt: DateTime.makeUnsafe(0), + }); + const context = handoff.summaryText; + assert.isBelow(summarizedItems, parentItems.length); - assert.include(context, "- Earlier items truncated."); - assert.notInclude(context, "item-0 "); - assert.include(context, "- Assistant: item-59 "); - assert.isAtMost(context.length, 8_000); - assert.isTrue( - context.endsWith( - `Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`, - ), - ); + assert.include(context, "- Earlier items truncated."); + assert.notInclude(context, "item-0 "); + assert.include(context, "- Assistant: item-59 "); + assert.isAtMost(context.length, 8_000); + assert.isTrue( + context.endsWith( + `Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`, + ), + ); - const framingOnly = ContextHandoffService.makeSideChatContext({ parentThreadId, digest: [] }); - assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread.")); - assert.notInclude(framingOnly, "Main thread activity"); -}); + const framingOnly = ContextHandoffService.makeSideChatContext({ + parentThreadId, + digest: [], + }); + assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread.")); + assert.notInclude(framingOnly, "Main thread activity"); + }).pipe(Effect.provide(layerTest)), +); it("covers the run range of a very long delta", () => { const runs = [ diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 67a474d963bb..7eef0afefbc1 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -202,7 +202,7 @@ export const readForkHistoryItems = Effect.fn( ]; }); -export function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { +function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { switch (item.type) { case "user_message": return `- User: ${compactText(item.text)}`; @@ -244,10 +244,10 @@ function makeForkDeltaSummary(input: { const SIDE_CHAT_CONTEXT_MAX_CHARS = 8_000; -/** `digest` holds `summarizeDeltaItem` lines for parent items after the fork point, oldest first. */ +/** Oldest first; raw parent items are summarized only until the context cap is reached. */ export function makeSideChatContext(input: { readonly parentThreadId: ThreadId; - readonly digest: ReadonlyArray; + readonly digest: ReadonlyArray; }): string { const framing = "You are in a side chat beside the main thread. The main thread may still be running in the same worktree. Answer the user's questions; do not continue or redo the main thread's task."; @@ -255,19 +255,24 @@ export function makeSideChatContext(input: { const truncated = "- Earlier items truncated."; const footer = `Use t3_thread_read on thread ${input.parentThreadId} to see the main thread's live state.`; const kept: Array = []; + let omitted = false; // The cap covers the whole block, so the fixed lines are reserved before any digest line. let remaining = SIDE_CHAT_CONTEXT_MAX_CHARS - [framing, "", header, truncated, "", footer].join("\n").length; - for (const line of input.digest.toReversed()) { - if (line.length + 1 > remaining) break; + for (let index = input.digest.length - 1; index >= 0; index--) { + const item = input.digest[index]!; + const line = typeof item === "string" ? item : summarizeDeltaItem(item); + if (line === null) continue; + if (line.length + 1 > remaining) { + omitted = true; + break; + } kept.unshift(line); remaining -= line.length + 1; } return [ framing, - ...(kept.length === 0 - ? [] - : ["", header, ...(kept.length < input.digest.length ? [truncated] : []), ...kept]), + ...(kept.length === 0 ? [] : ["", header, ...(omitted ? [truncated] : []), ...kept]), "", footer, ].join("\n"); @@ -588,10 +593,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi summaryMessageId: null, summaryText: makeSideChatContext({ parentThreadId: input.parentThreadId, - digest: input.parentItems.flatMap((item) => { - const line = summarizeDeltaItem(item); - return line === null ? [] : [line]; - }), + digest: input.parentItems, }), createdByProviderInstanceId: null, createdAt: input.createdAt, From 39bca5365c296dee18450fe8b43ecb45e94fe9fe Mon Sep 17 00:00:00 2001 From: Yuruzuu <108398860+Yuruzuu@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:49:17 +0800 Subject: [PATCH 150/158] perf(web): typing in a thread no longer re-renders sidebar drafts (#28267) Co-authored-by: Claude Opus 5.5 --- .../SidebarDraftBlock.browser.test.tsx | 68 +++++++++++++++++++ .../components/sidebar/SidebarDraftBlock.tsx | 11 ++- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx new file mode 100644 index 000000000000..c29932f5757e --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -0,0 +1,68 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { Profiler } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { SidebarDraftBlock } from "./SidebarDraftBlock"; + +it("keeps draft previews current through edits, promotion and removal", async () => { + const initial = useComposerDraftStore.getState(); + using _store = { + [Symbol.dispose]: () => useComposerDraftStore.setState(initial, true), + }; + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + const environmentId = EnvironmentId.make("draft-preview-environment"); + const projectRef = scopeProjectRef(environmentId, ProjectId.make("draft-preview-project")); + const draftId = DraftId.make("draft-preview"); + const threadId = ThreadId.make("draft-preview-thread"); + const onRender = vi.fn(); + await using screen = await render( + +
    + +
+
, + ); + const row = screen.getByTestId("sidebar-draft-row"); + await expect.element(row).not.toBeInTheDocument(); + + await act(() => { + initial.setProjectDraftThreadId(projectRef, draftId, { threadId }); + initial.setPrompt(draftId, "First draft preview"); + }); + await expect.element(row).toHaveAccessibleName("First draft preview, Unsent draft"); + + const rendersBeforeOtherComposer = onRender.mock.calls.length; + await act(() => + initial.setPrompt(scopeThreadRef(environmentId, ThreadId.make("other")), "Other composer"), + ); + expect(onRender).toHaveBeenCalledTimes(rendersBeforeOtherComposer); + await act(() => initial.setPrompt(draftId, "Updated draft preview")); + await expect.element(row).toHaveAccessibleName("Updated draft preview, Unsent draft"); + + await act(() => initial.markDraftThreadPromoting(draftId)); + await expect.element(row).not.toBeInTheDocument(); + await act(() => initial.clearDraftThread(draftId)); + + const nextDraftId = DraftId.make("next-draft-preview"); + await act(() => { + initial.setProjectDraftThreadId(projectRef, nextDraftId, { threadId: ThreadId.make("next") }); + initial.setPrompt(nextDraftId, "Next draft preview"); + }); + await expect.element(row).toHaveAccessibleName("Next draft preview, Unsent draft"); + await act(() => initial.clearDraftThread(nextDraftId)); + await expect.element(row).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index a9301f30cb0e..10581ecb5a67 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -8,6 +8,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useShallow } from "zustand/react/shallow"; import { cn } from "~/lib/utils"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; @@ -147,7 +148,15 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const draftsByThreadKey = useComposerDraftStore( + useShallow((store) => + Object.fromEntries( + Object.keys(store.draftThreadsByThreadKey).map( + (key) => [key, store.draftsByThreadKey[key]] as const, + ), + ), + ), + ); const [frozenActive, setFrozenActive] = useState<{ routeDraftId: string | null; row: SidebarDraftRowData | null; From ee29f11eb593d25f7e6833162aa0af36b59279a4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:49:33 -0500 Subject: [PATCH 151/158] fix(scripts): Alchemy reference sync reads the catalog version (#28268) Co-authored-by: Claude Opus 5.5 --- scripts/lib/reference-repos.ts | 4 ++-- scripts/sync-reference-repos.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/lib/reference-repos.ts b/scripts/lib/reference-repos.ts index 005c619ff24b..23250f8116d1 100644 --- a/scripts/lib/reference-repos.ts +++ b/scripts/lib/reference-repos.ts @@ -23,8 +23,8 @@ export const referenceRepos: ReadonlyArray = [ prefix: ".repos/alchemy-effect", repository: "https://github.com/alchemy-run/alchemy-effect.git", latestRef: "main", - versionSourcePath: "infra/relay/package.json", - packageVersionPath: ["dependencies", "alchemy"], + versionSourcePath: "pnpm-workspace.yaml", + packageVersionPath: ["catalog", "alchemy"], versionTagPrefix: "v", }, ]; diff --git a/scripts/sync-reference-repos.test.ts b/scripts/sync-reference-repos.test.ts index 9972d180e373..022d7eb5a38d 100644 --- a/scripts/sync-reference-repos.test.ts +++ b/scripts/sync-reference-repos.test.ts @@ -8,9 +8,10 @@ import { referenceRepos } from "./lib/reference-repos.ts"; import { planReferenceRepoSync, resolveReferenceRepoRef } from "./sync-reference-repos.ts"; const effectSmol = referenceRepos[0]!; +const alchemyEffect = referenceRepos[1]!; it.layer(NodeServices.layer)("sync-reference-repos", (it) => { - it.effect("resolves the effect-smol tag from the root catalog", () => + it.effect("resolves reference tags from the root catalog", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -19,13 +20,14 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => { }); yield* fs.writeFileString( path.join(rootDir, "pnpm-workspace.yaml"), - "catalog:\n effect: 4.0.0-beta.73\n", + "catalog:\n alchemy: 2.0.0-beta.84\n effect: 4.0.0-beta.73\n", ); assert.equal( yield* resolveReferenceRepoRef(effectSmol, rootDir, false), "effect@4.0.0-beta.73", ); + assert.equal(yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false), "v2.0.0-beta.84"); }), ); From 96b03779ca5161a1bcd2b73bffdc3a62c3980e8d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:49:49 -0500 Subject: [PATCH 152/158] docs(mobile): mobile README links the store apps instead of calling the app unreleased (#28269) Co-authored-by: Claude Opus 5.5 --- apps/mobile/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 1b033082d58f..d508cea8ec2b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,7 +1,7 @@ # T3 Code Mobile -> [!WARNING] -> T3 Code Mobile is currently in development and is not distributed yet. If you want to try it out, you can build it from source. +> [!NOTE] +> To install the released app from the App Store or Google Play, see [Mobile app](../../docs/user/install.md#mobile-app). This guide covers building it from source. ## Quickstart From 35df72f26864bd120102ce91cad66affbe8ba89c Mon Sep 17 00:00:00 2001 From: Yuruzuu <108398860+Yuruzuu@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:50:03 +0800 Subject: [PATCH 153/158] perf(web): render minimap highlights without extra nodes (#28270) --- apps/web/src/components/chat/TimelineMinimap.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/TimelineMinimap.tsx b/apps/web/src/components/chat/TimelineMinimap.tsx index 4e87edfe5949..742657ba68c0 100644 --- a/apps/web/src/components/chat/TimelineMinimap.tsx +++ b/apps/web/src/components/chat/TimelineMinimap.tsx @@ -217,7 +217,7 @@ export function TimelineMinimap({