From df7ccc8fd01f5d2a1d8ec21bef8f9c59398fe913 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 19:27:14 -0300 Subject: [PATCH 1/5] feat(web): refine compact thread row badges (#11644) --- apps/web/src/components/Sidebar.tsx | 106 +++++++++++++----- .../src/components/ThreadStatusIndicators.tsx | 5 +- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ea25b5c2c5c4..65ac1cf7752c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1584,7 +1584,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); - const prBadge = + const renderPrBadge = (iconOnly: boolean) => prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( ) : null; + const prBadge = renderPrBadge(false); const terminalStatusIcon = terminalStatus ? ( } > - {props.project ? ( - - ) : driverKind ? ( - - ) : ( - - )} + + {props.project ? ( + + ) : driverKind ? ( + + ) : ( + + )} + {isRemote ? ( + + + } + > + + + + {props.environmentLabel ?? "Remote environment"} + + + ) : null} + {topStatus ? ( {draftIndicator} {props.project ? ( - + + + {compactRows && isRemote ? ( + + + } + > + + + + {props.environmentLabel ?? "Remote environment"} + + + ) : null} + + ) : compactRows && isRemote ? ( + + + } + > + + + + {props.environmentLabel ?? "Remote environment"} + + ) : null} {compactRows ? ( title @@ -1962,18 +2024,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {pinIndicator} {compactRows ? ( <> - {isRemote ? ( - - - - ) : null} {terminalStatusIcon} {topStatus && CompactStatusIcon ? ( isWokeStatus ? ( @@ -2001,7 +2051,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : null} - {prBadge} + {renderPrBadge(true)} ) : null} {/* The visible state owns this slot's width: status at rest, @@ -2031,7 +2081,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { > {compactRows ? ( status === "working" ? ( - + + + ) : compactCompletedAt ? ( ) : ( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 7201779f13f1..bb99df0a3d32 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -144,6 +144,7 @@ export function ThreadPullRequestBadgeControl({ number, url, status, + iconOnly = false, onOpenStack, onOpenPullRequest, }: { @@ -152,6 +153,8 @@ export function ThreadPullRequestBadgeControl({ number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; + /** Dense rows drop the number/layer count and keep only the state glyph. */ + iconOnly?: boolean; onOpenStack: () => void; onOpenPullRequest: (event: MouseEvent) => void; }) { @@ -179,7 +182,7 @@ export function ThreadPullRequestBadgeControl({ const content = ( <> - {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} + {iconOnly ? null : isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} ); return ( From 7b61099886f3aac139ad43fb73873c73ba7baf4f Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 20:09:18 -0300 Subject: [PATCH 2/5] feat(web): show the linked pull request in the compact sidebar rail (#11652) --- apps/web/src/components/Sidebar.tsx | 10 ++++++++-- apps/web/src/components/ThreadStatusIndicators.tsx | 11 ++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 65ac1cf7752c..9cee123e4ecf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1584,10 +1584,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); - const renderPrBadge = (iconOnly: boolean) => + const renderPrBadge = (iconOnly: boolean, variant: "underline" | "badge" = "underline") => prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( ) : null; + const hasPrBadge = prBadgeShape?.kind === "stack" || pr !== null || currentLinkedPr !== null; const prBadge = renderPrBadge(false); const terminalStatusIcon = terminalStatus ? ( ) : null} + {hasPrBadge ? ( + + {renderPrBadge(true, "badge")} + + ) : null} {topStatus ? ( - + {iconOnly ? null : isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} ); From 9086a1f7174c9a66c29c96bf0e3a2ddc33c5a507 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 16:22:39 -0700 Subject: [PATCH 3/5] fix(mobile): adopt system glass for Live Activities (#11604) --- apps/mobile/package.json | 2 +- apps/mobile/src/widgets/AgentActivity.test.ts | 1 + apps/mobile/src/widgets/AgentActivity.tsx | 32 ++-- patches/expo-widgets@57.0.15.patch | 148 ++++++++++++++++++ pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 1 + 6 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 patches/expo-widgets@57.0.15.patch diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0fd35faf7528..18fb67319176 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -106,7 +106,7 @@ "expo-updates": "~57.0.19", "expo-video": "~57.0.3", "expo-web-browser": "~57.0.2", - "expo-widgets": "~57.0.15", + "expo-widgets": "57.0.15", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index dc9cd0e22117..467f290a1575 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -10,6 +10,7 @@ vi.mock("@expo/ui/swift-ui", () => ({ })); vi.mock("@expo/ui/swift-ui/modifiers", () => ({ + activityBackgroundTint: (value: unknown) => value, font: (value: unknown) => value, foregroundStyle: (value: unknown) => value, frame: (value: unknown) => value, diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 88c85648271f..3e9b417c5aae 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -1,6 +1,7 @@ import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; import type { ComponentProps } from "react"; import { + activityBackgroundTint, font, foregroundStyle, frame, @@ -56,14 +57,13 @@ export function AgentActivity( ): LiveActivityLayout { "widget"; - // Use SwiftUI's semantic label colors rather than fixed hex keyed off the - // device color scheme. A Live Activity banner always renders over a dark - // system material regardless of the device's light/dark setting, so - // scheme-derived dark text read as unreadable dark-on-dark on the lock - // screen. Semantic colors adapt to whatever material the OS places them on: - // the dark LA banner and the (light or dark) home-screen widget alike. - const primaryForeground = "primary"; - const secondaryForeground = "secondary"; + // Hierarchical styles inherit the system's foreground treatment, including + // tinted and vibrant presentations, rather than resolving to a label color. + type Foreground = Parameters[0]; + const primaryForeground = { type: "hierarchical", style: "primary" } as const; + const secondaryForeground = { type: "hierarchical", style: "secondary" } as const; + const monochrome = + environment.widgetRenderingMode === "accented" || environment.widgetRenderingMode === "vibrant"; // Status tints mirror the web sidebar's pills // (apps/web/src/components/Sidebar.logic.ts resolveThreadStatusPill): amber @@ -72,10 +72,13 @@ export function AgentActivity( // Mac notification center) renders it on a light one — so pick the web // palette's light (-600) or dark (-300) variant off the color scheme. const isLightScheme = environment.colorScheme === "light"; - const phaseTint = (phase: AgentActivityPhase | undefined): string => { + const phaseTint = (phase: AgentActivityPhase | undefined): Foreground => { if (environment.isLuminanceReduced) { return secondaryForeground; } + if (monochrome) { + return primaryForeground; + } switch (phase) { case "waiting_for_approval": return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300 @@ -179,7 +182,7 @@ export function AgentActivity( // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to // the image; size + tint them through a container the resizable symbol fills. - const renderGlyph = (systemName: SFName, size: number, color: string) => ( + const renderGlyph = (systemName: SFName, size: number, color: Foreground) => ( @@ -229,7 +232,7 @@ export function AgentActivity( // frame the resizable image fills and tint it through the container's // foreground style, which the template image inherits. The 3:2 frame matches // the glyph's aspect ratio so it never distorts. - const renderLogo = (height: number, color: string) => ( + const renderLogo = (height: number, color: Foreground) => ( @@ -240,7 +243,12 @@ export function AgentActivity( {/* Logo pinned to the leading edge; the status texts centered across the full width (ZStack so the logo doesn't skew the centering). No footer — diff --git a/patches/expo-widgets@57.0.15.patch b/patches/expo-widgets@57.0.15.patch new file mode 100644 index 000000000000..f50a3fa4f661 --- /dev/null +++ b/patches/expo-widgets@57.0.15.patch @@ -0,0 +1,148 @@ +diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts +index 15e026868625ce20a3e359e5cc8e5c7e937c4650..f5cc83174ede4fb6e01cbce494f61e088eb4eed8 100644 +--- a/build/Widgets.types.d.ts ++++ b/build/Widgets.types.d.ts +@@ -90,6 +90,10 @@ export type LiveActivityEnvironment = { + * The color scheme of the activity's environment. + */ + colorScheme: 'light' | 'dark'; ++ /** The rendering mode supplied by WidgetKit for this presentation. */ ++ widgetRenderingMode?: WidgetRenderingMode; ++ /** Whether the presentation OS supports Liquid Glass. @platform iOS 26+ */ ++ isLiquidGlassAvailable?: boolean; + /** + * Whether the activity is displayed in a context with reduced luminance. + * @platform iOS 16+ +diff --git a/ios/Widgets/Utils.swift b/ios/Widgets/Utils.swift +index 2b4c044b597de7e0fab42dbc47af14d8bb521838..801f0aed65ea2659f221d477dd841787d9146c26 100644 +--- a/ios/Widgets/Utils.swift ++++ b/ios/Widgets/Utils.swift +@@ -91,6 +91,10 @@ func getLiveActivityEnvironment(for environment: EnvironmentValues, in context: + "colorScheme": "\(environment.colorScheme)" + ] + ++ if #available(iOS 26.0, *) { ++ env["isLiquidGlassAvailable"] = true ++ } ++ env["widgetRenderingMode"] = environment.widgetRenderingMode.description + env["isLuminanceReduced"] = environment.isLuminanceReduced + env["isActivityFullscreen"] = environment.isActivityFullscreen + if #available(iOS 16.2, *) { +diff --git a/ios/Widgets/WidgetLiveActivity.swift b/ios/Widgets/WidgetLiveActivity.swift +index 24d4571a52d3ebe9d785b5d049d8e134a149d513..7051c23b17ac91552e5b5c54606c57086c0ebbea 100644 +--- a/ios/Widgets/WidgetLiveActivity.swift ++++ b/ios/Widgets/WidgetLiveActivity.swift +@@ -20,53 +20,41 @@ struct LiveActivityAttributes: ActivityAttributes { + + @available(iOS 16.1, *) + public struct WidgetLiveActivity: Widget { +- @Environment(\.self) var env +- + let widgetContext: AppContext = AppContext() + + public init() {} + + public var body: some WidgetConfiguration { + ActivityConfiguration(for: LiveActivityAttributes.self) { context in +- let nodes = getLiveActivityNodes( +- forName: context.state.name, +- props: context.state.props, +- environment: getLiveActivityEnvironment(for: env, in: context) +- ) + // Only apply widgetURL when the activity has one: a hierarchy with more than one + // widgetURL modifier is undefined behavior, and layouts can set their own through + // the widgetURL modifier from @expo/ui. +- let banner = LiveActivityBannerView(context: context, nodes: nodes) ++ let banner = LiveActivityBannerView(context: context) + if let url = context.attributes.url.flatMap(URL.init(string:)) { + banner.widgetURL(url) + } else { + banner + } + } dynamicIsland: { context in +- let nodes = getLiveActivityNodes( +- forName: context.state.name, +- props: context.state.props, +- environment: getLiveActivityEnvironment(for: env, in: context) +- ) + let island = DynamicIsland { + DynamicIslandExpandedRegion(.center) { +- LiveActivitySectionView(context: context, nodes: nodes, sectionName: "expandedCenter") ++ LiveActivitySectionView(context: context, sectionName: "expandedCenter") + } + DynamicIslandExpandedRegion(.leading) { +- LiveActivitySectionView(context: context, nodes: nodes, sectionName: "expandedLeading") ++ LiveActivitySectionView(context: context, sectionName: "expandedLeading") + } + DynamicIslandExpandedRegion(.trailing) { +- LiveActivitySectionView(context: context, nodes: nodes, sectionName: "expandedTrailing") ++ LiveActivitySectionView(context: context, sectionName: "expandedTrailing") + } + DynamicIslandExpandedRegion(.bottom) { +- LiveActivitySectionView(context: context, nodes: nodes, sectionName: "expandedBottom") ++ LiveActivitySectionView(context: context, sectionName: "expandedBottom") + } + } compactLeading: { +- LiveActivitySectionView(context: context, nodes: nodes, sectionName: "compactLeading") ++ LiveActivitySectionView(context: context, sectionName: "compactLeading") + } compactTrailing: { +- LiveActivitySectionView(context: context, nodes: nodes, sectionName: "compactTrailing") ++ LiveActivitySectionView(context: context, sectionName: "compactTrailing") + } minimal: { +- LiveActivitySectionView(context: context, nodes: nodes, sectionName: "minimal") ++ LiveActivitySectionView(context: context, sectionName: "minimal") + } + if let url = context.attributes.url.flatMap(URL.init(string:)) { + return island.widgetURL(url) +@@ -79,11 +67,16 @@ public struct WidgetLiveActivity: Widget { + + @available(iOS 16.1, *) + private struct LiveActivitySectionView: View { ++ @Environment(\.self) var env + let context: ActivityViewContext +- let nodes: [String: Any] + let sectionName: String + + var body: some View { ++ let nodes = getLiveActivityNodes( ++ forName: context.state.name, ++ props: context.state.props, ++ environment: getLiveActivityEnvironment(for: env, in: context) ++ ) + if let node = nodes[sectionName] as? [String: Any] { + WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node) + } else { +@@ -94,10 +87,17 @@ private struct LiveActivitySectionView: View { + + @available(iOS 16.1, *) + private struct LiveActivityBannerView: View { ++ // Read inside the rendered view: Widget-level values do not include the ++ // presentation's color scheme, rendering mode, or reduced-luminance state. ++ @Environment(\.self) var env + var context: ActivityViewContext +- let nodes: [String: Any] + + var body: some View { ++ let nodes = getLiveActivityNodes( ++ forName: context.state.name, ++ props: context.state.props, ++ environment: getLiveActivityEnvironment(for: env, in: context) ++ ) + if #available(iOS 18.0, *) { + LiveActivityBanner(context: context, nodes: nodes) + } else if let node = nodes["banner"] as? [String: Any] { +diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts +index fee5c7475b52218fa0e192a1d347dd32cb0fe1f8..1d3a097b45d8eb5d13e5cedd26f0414caa83a5d6 100644 +--- a/src/Widgets.types.ts ++++ b/src/Widgets.types.ts +@@ -104,6 +104,10 @@ export type LiveActivityEnvironment = { + * The color scheme of the activity's environment. + */ + colorScheme: 'light' | 'dark'; ++ /** The rendering mode supplied by WidgetKit for this presentation. */ ++ widgetRenderingMode?: WidgetRenderingMode; ++ /** Whether the presentation OS supports Liquid Glass. @platform iOS 26+ */ ++ isLiquidGlassAvailable?: boolean; + /** + * Whether the activity is displayed in a context with reduced luminance. + * @platform iOS 16+ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8dbaf396924..06b0578ae057 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,7 @@ patchedDependencies: effect@4.0.0-rc.112: 8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 + expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6 react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 @@ -414,8 +415,8 @@ importers: specifier: ~57.0.2 version: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: - specifier: ~57.0.15 - version: 57.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 57.0.15 + version: 57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -16861,7 +16862,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) - expo-widgets: 57.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-widgets: 57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -18179,7 +18180,7 @@ snapshots: expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@57.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-widgets@57.0.15(patch_hash=319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6)(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/plist': 0.8.1 '@expo/ui': 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8a4bf972f82f..e2c683e9bab3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -176,6 +176,7 @@ patchedDependencies: effect@4.0.0-rc.112: patches/effect@4.0.0-rc.112.patch expo-audio@57.0.4: patches/expo-audio@57.0.4.patch expo-sharing@57.0.17: patches/expo-sharing@57.0.17.patch + expo-widgets@57.0.15: patches/expo-widgets@57.0.15.patch react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch From 3689c98d2739c6e5bf788850bc1a82a7372d313d Mon Sep 17 00:00:00 2001 From: Dominic Roy Date: Sun, 13 Sep 2026 19:55:02 -0400 Subject: [PATCH 4/5] fix(web): separate expanded tool output from adjacent hover highlights (#11658) --- apps/web/src/components/chat/MessagesTimeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 568a86721e4c..f388db604450 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4063,6 +4063,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { className={cn( "flex flex-col rounded-md px-0.5 transition-colors", isExpandedToolGroupEntry ? "py-0" : "py-0.5", + expanded && "mb-1", canExpand && "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", )} From 66e39ca2aabde054bc50312a9c34f05dbd1f6f9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 17:08:41 -0700 Subject: [PATCH 5/5] fix(web): apply device settings to selected environments (#11541) --- .../settings/DeviceHostsSettings.tsx | 392 ++++++++++-------- .../settings/IntegrationsSettings.test.tsx | 10 +- .../settings/IntegrationsSettings.tsx | 87 ++-- .../deviceHostsSettings.logic.test.ts | 57 +++ .../settings/deviceHostsSettings.logic.ts | 35 ++ .../settings/scopedSettings.test.ts | 13 + 6 files changed, 401 insertions(+), 193 deletions(-) create mode 100644 apps/web/src/components/settings/deviceHostsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/deviceHostsSettings.logic.ts diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index e61cf742dfd5..c5a5f194d7cd 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -19,67 +19,75 @@ import { MoreVertical, PlusIcon } from "lucide-react"; import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; import { SettingsRow } from "./settingsLayout"; -/** Host names and identity paths belong to the selected environment, never all environments. */ -export function DeviceHostsSettings(props: { - environmentId: EnvironmentId | null; - hosts: ReadonlyArray; -}) { - const update = useAtomCommand(serverEnvironment.updateSettings); - const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); - const { state } = useDeviceState(props.environmentId); +import { useSettingsScope } from "./SettingsScopeContext"; +import { toastManager } from "../ui/toast"; +import { updateDeviceHosts } from "./deviceHostsSettings.logic"; + +export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { + const { scope, environments, connectedEnvironments, environment: selected } = useSettingsScope(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const [editing, setEditing] = useState(null); + const [originalHost, setOriginalHost] = useState(null); const [busy, setBusy] = useState(false); const validPort = (port: number | undefined) => port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); - const [checks, setChecks] = useState< - Record< - string, - { pending?: boolean; platforms?: ReadonlyArray; error?: string } - > - >({}); - const setCheck = (id: string, value: (typeof checks)[string]) => - setChecks((current) => ({ ...current, [id]: value })); - const save = async (hosts: ReadonlyArray) => { - if (!props.environmentId) return; + const { checks, testConnection } = useHostConnectionChecks(props.environmentId); + const save = async (host: SshDeviceHostConfig, remove = false, original = host) => { + if (!props.environmentId || projectScope) return; setBusy(true); try { - const saved = await update({ - environmentId: props.environmentId, - input: { patch: { deviceHosts: hosts } }, + const results = await Promise.allSettled( + environments.map(async (environment) => { + if (environment.connection.phase !== "connected" || !environment.serverConfig) { + throw new Error("Environment disconnected"); + } + return update({ + environmentId: environment.environmentId, + input: { + patch: { + deviceHosts: updateDeviceHosts( + environment.serverConfig.settings.deviceHosts, + host, + remove, + original, + ), + }, + }, + }); + }), + ); + const failed = environments.filter((_, index) => { + const result = results[index]; + return result?.status !== "fulfilled" || result.value._tag === "Failure"; }); - if (saved._tag === "Success") { + if (failed.length === 0) { setEditing(null); + } else { + toastManager.add({ + type: "error", + title: "Device hosts not saved on all environments", + description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`, + }); } } finally { setBusy(false); } }; - const testConnection = async (host: SshDeviceHostConfig) => { - if (!props.environmentId || checks[host.id]?.pending) return; - setCheck(host.id, { pending: true }); - try { - const summary = await test({ environmentId: props.environmentId, input: host }); - setCheck( - host.id, - summary._tag === "Failure" - ? { error: Cause.pretty(summary.cause) } - : { platforms: summary.value.platforms }, - ); - } catch (error) { - setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); - } - }; return ( { + setOriginalHost(null); setEditing({ id: randomUUID(), label: "", target: "" }); }} > @@ -90,131 +98,35 @@ export function DeviceHostsSettings(props: {
{!props.environmentId ? (

- Select one connected environment to manage its device hosts. + Connect a selected environment to manage device hosts.

) : ( <> - {props.hosts.map((host) => { - const status = state.hostStatuses[host.id]; - const check = checks[host.id]; - const platforms = - check?.platforms ?? - state.hosts.find((value) => value.id === host.id)?.platforms ?? - []; - const progress = check?.pending - ? "Checking connection…" - : status?.status === "installing" - ? "Installing device support…" - : status?.status === "starting" - ? "Connecting…" - : null; - const error = - check?.error ?? (status?.status === "failed" ? status.detail : undefined); - return ( -
-
-
-

{host.label}

- {platforms - .filter((platform) => platform.available) - .map((platform) => ( - - - } - > - {platform.platform === "ios" ? ( - - ) : ( - - )} - - - {platform.platform === "ios" ? "iOS available" : "Android available"} - - - ))} -
-

{host.target}

- {error ? ( -
-
- Connection failed -

{error}

-
-
- ) : null} -
- {progress ? ( - - - {progress} - - ) : null} - - - } - > - - - - { - setEditing(host); - }} - > - Edit - - - void save(props.hosts.filter((value) => value.id !== host.id)) - } - > - Remove - - - - -
- ); - })} + {connectedEnvironments.map((environment) => ( +
+ {connectedEnvironments.length > 1 ? ( +

+ {environment.label} +

+ ) : null} + { + setOriginalHost(host); + setEditing(host); + }} + onRemove={(host) => void save(host, true)} + /> +
+ ))} {editing ? (
{ event.preventDefault(); - void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); + void save(editing, false, originalHost ?? editing); }} >
+ ); + })} + + ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 9ecd469f310c..15b19029e818 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -25,10 +25,11 @@ vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [], isReady: true }), usePrimaryEnvironment: () => null, })); -vi.mock("../../hooks/useSettings", () => ({ +vi.mock("../../hooks/useSettings", async (importOriginal) => ({ + ...(await importOriginal()), PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment", - useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => - selector(DEFAULT_CLIENT_SETTINGS), + useClientSettings: (selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS, useClientSettingsHydrated: () => true, usePrimarySettingsAvailable: () => true, usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS, @@ -44,7 +45,10 @@ vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => nul vi.mock("./SettingsScopeContext", () => ({ useSettingsScope: () => ({ scope: { kind: "all", environmentIds: [] }, + search: {}, environment: null, + environments: [], + target: null, connectedEnvironments: [], targets: [], }), diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index cd759ab8323c..db6276d94685 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,5 @@ +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { ScopedSwitch } from "./ScopedSwitch"; import { DeviceHostsSettings } from "./DeviceHostsSettings"; /** * Integrations settings - preferences for surfaces T3 Code embeds rather than @@ -13,7 +15,6 @@ import { type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, - type SshDeviceHostConfig, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, @@ -565,28 +566,19 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) ); } -/** - * Device support installs helper processes and hosts on one machine, so it - * follows the environment crumb. With several environments selected it shows - * the representative, named in the section title. - */ function DeviceIntegrationSettings() { - const { scope, environment: selected, connectedEnvironments } = useSettingsScope(); + const { search, environment: selected } = useSettingsScope(); + const settings = useScopedSettings(); const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; const environmentId = connected ? selected.environmentId : null; - const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1; return ( - + ); @@ -594,17 +586,21 @@ function DeviceIntegrationSettings() { function DeviceIntegrationControls({ environmentId, - hosts, enabled, agentAccessEnabled, }: { environmentId: EnvironmentId | null; - hosts: ReadonlyArray; enabled: boolean; agentAccessEnabled: boolean; }) { const { state, loaded } = useDeviceState(environmentId); - const configure = useAtomCommand(deviceEnvironment.configure); + const { scope, environments, connectedEnvironments } = useSettingsScope(); + const updateSettings = useUpdateScopedSettings(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const anyHubEnabled = connectedEnvironments.some( + (environment) => environment.serverConfig?.settings.enableDeviceSupport, + ); + const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; @@ -622,9 +618,27 @@ function DeviceIntegrationControls({ if (!environmentId) return; setPending(kind); try { - const result = await configure({ environmentId, input }); - if (result._tag === "Success" && input.enabled === true && !state.onboardingCompleted) { - await configure({ environmentId, input: { onboardingCompleted: true } }); + const results = await Promise.allSettled( + environments.map(async (environment) => { + if (environment.connection.phase !== "connected" || !environment.serverConfig) { + throw new Error("Environment disconnected"); + } + return configure({ + environmentId: environment.environmentId, + input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) }, + }); + }), + ); + const failed = environments.filter((_, index) => { + const result = results[index]; + return result?.status !== "fulfilled" || result.value._tag === "Failure"; + }); + if (failed.length > 0) { + toastManager.add({ + type: "error", + title: "Device settings not saved on all environments", + description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`, + }); } } finally { setPending(null); @@ -635,13 +649,16 @@ function DeviceIntegrationControls({ <> {pending === "hub" ? : null} - void update("hub", { @@ -657,6 +674,11 @@ function DeviceIntegrationControls({ {platformsRevealed ? ( 1 + ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.` + : undefined + } status={
@@ -686,16 +708,25 @@ function DeviceIntegrationControls({ {pending === "agent" ? : null} - - void update("agent", { agentAccessEnabled: Boolean(checked) }) + projectScope + ? updateSettings({ enableAgentDeviceAccess: Boolean(checked) }) + : void update("agent", { agentAccessEnabled: Boolean(checked) }) } /> @@ -706,7 +737,7 @@ function DeviceIntegrationControls({ {state.hostStatusDetail}

) : null} - + ); } diff --git a/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts new file mode 100644 index 000000000000..274cf7ecd9ab --- /dev/null +++ b/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; +import { updateDeviceHosts } from "./deviceHostsSettings.logic"; + +describe("device host changes across environments", () => { + const shared = { id: "shared", label: "Mac mini", target: "julius@macmini" }; + const local = { id: "other", label: "Android", target: "julius@android" }; + + it("adds to differing host lists without losing environment-specific hosts, including on retry", () => { + const environments = [[], [local], [shared, local]]; + const saved = environments.map((hosts) => updateDeviceHosts(hosts, shared, false)); + expect(saved).toEqual([[shared], [local, shared], [shared, local]]); + expect(saved.map((hosts) => updateDeviceHosts(hosts, shared, false))).toEqual(saved); + }); + + it("edits and removes the shared host while preserving unrelated hosts", () => { + const edited = { ...shared, target: "julius@new-address" }; + const saved = [[shared], [local, shared]].map((hosts) => + updateDeviceHosts(hosts, edited, false), + ); + expect(saved).toEqual([[edited], [local, edited]]); + expect(saved.map((hosts) => updateDeviceHosts(hosts, edited, true))).toEqual([[], [local]]); + }); + + it("recognizes a host added separately on another environment and preserves its local ID", () => { + const remote = { ...shared, id: "remote-id" }; + const edited = { ...shared, target: "julius@new-address" }; + expect(updateDeviceHosts([remote, local], shared, false)).toEqual([remote, local]); + const saved = updateDeviceHosts([remote, local], edited, false, shared); + expect(saved).toEqual([{ ...edited, id: remote.id }, local]); + expect(updateDeviceHosts(saved, edited, false, shared)).toEqual(saved); + expect(updateDeviceHosts([remote, local], shared, true)).toEqual([local]); + }); + + it("keeps distinct SSH connections to the same target separate", () => { + const anotherPort = { ...shared, id: "another-port", port: 2222 }; + const anotherIdentity = { ...shared, id: "another-key", identityFile: "~/.ssh/another" }; + expect(updateDeviceHosts([anotherPort, anotherIdentity], shared, false)).toEqual([ + anotherPort, + anotherIdentity, + shared, + ]); + }); + + it("prefers the selected ID over a sibling with the same destination", () => { + const sibling = { ...shared, id: "sibling", label: "Another entry" }; + const edited = { ...shared, label: "Renamed" }; + expect(updateDeviceHosts([sibling, shared], edited, false, shared)).toEqual([sibling, edited]); + expect(updateDeviceHosts([sibling, shared], shared, true)).toEqual([sibling]); + }); + + it("refuses an ambiguous destination on another environment instead of changing a sibling", () => { + const remote = { ...shared, id: "remote" }; + const sibling = { ...shared, id: "sibling" }; + expect(() => updateDeviceHosts([remote, sibling], shared, true)).toThrow("Multiple hosts"); + expect(() => updateDeviceHosts([remote, sibling], shared, false)).toThrow("Multiple hosts"); + }); +}); diff --git a/apps/web/src/components/settings/deviceHostsSettings.logic.ts b/apps/web/src/components/settings/deviceHostsSettings.logic.ts new file mode 100644 index 000000000000..ae633b0c3eb6 --- /dev/null +++ b/apps/web/src/components/settings/deviceHostsSettings.logic.ts @@ -0,0 +1,35 @@ +import type { SshDeviceHostConfig } from "@t3tools/contracts"; + +/** Apply one host change without replacing another environment's host list. */ +export function updateDeviceHosts( + hosts: ReadonlyArray, + host: SshDeviceHostConfig, + remove: boolean, + original = host, +): ReadonlyArray { + const sameDestination = (candidate: SshDeviceHostConfig, other: SshDeviceHostConfig) => + candidate.target === other.target && + candidate.port === other.port && + candidate.identityFile === other.identityFile; + const findDestination = (destination: SshDeviceHostConfig) => { + const matches = hosts.filter((candidate) => sameDestination(candidate, destination)); + if (matches.length > 1) { + throw new Error( + "Multiple hosts match this SSH destination. Select the environment to edit its hosts.", + ); + } + return matches[0]; + }; + // A retry can encounter the updated destination on an environment that + // already saved, including one with a different environment-local host ID. + const existing = + hosts.find((candidate) => candidate.id === original.id) ?? + findDestination(original) ?? + (remove ? undefined : findDestination(host)); + if (remove) return hosts.filter((candidate) => candidate.id !== existing?.id); + return existing + ? hosts.map((candidate) => + candidate.id === existing.id ? { ...host, id: existing.id } : candidate, + ) + : [...hosts, host]; +} diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 707c82c81ec6..b0cf734a01cb 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -231,6 +231,19 @@ describe("scoped settings writes", () => { }); }); + it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => { + const plan = planScopedSettingsPatch(project, [laptop, server], { + enableAgentDeviceAccess: true, + }); + expect(plan.serverWrites.map((write) => write.patch)).toEqual([ + { projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } } }, + { projectSettingsOverrides: { [laptopProjectId]: { enableAgentDeviceAccess: true } } }, + ]); + for (const patch of [{ enableDeviceSupport: true }, { deviceHosts: [] }]) { + expect(planScopedSettingsPatch(project, environments, patch).serverWrites).toEqual([]); + } + }); + it("refuses environment-wide keys and older servers at project scope", () => { expect( planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),