From 2a7a449cca3c7760b689a54b3aa02476a206dbc9 Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 2 Sep 2026 21:47:46 +0200 Subject: [PATCH 01/29] fix(web): hide deleted providers with prototype keys (#8337) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/web/src/providerInstances.test.ts | 71 ++++++++++++++++++++++++++ apps/web/src/providerInstances.ts | 18 ++++--- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index b64a5e25d508..3e809e17f588 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -119,6 +119,77 @@ describe("applyProviderInstanceSettings", () => { expect(entry?.enabled).toBe(false); }); + + it.each(["constructor", "toString"])( + "treats a removed custom instance named %s as disabled", + (instanceId) => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId, + }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: {}, + providers: {} as never, + }); + + expect(entry?.enabled).toBe(false); + }, + ); + + it("uses settings for a configured custom instance named constructor", () => { + const instanceId = ProviderInstanceId.make("constructor"); + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId, + }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, + }, + providers: {} as never, + }); + + expect(entry?.enabled).toBe(false); + }); + + it("treats a removed default instance for a fork driver as disabled", () => { + const driver = ProviderDriverKind.make("constructor"); + const entries = deriveProviderInstanceEntries([ + provider({ + provider: driver, + instanceId: "constructor", + }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: {}, + providers: {} as never, + }); + + expect(entry?.isDefault).toBe(true); + expect(entry?.enabled).toBe(false); + }); + + it("uses legacy settings for a built-in default instance", () => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + }), + ]); + const [entry] = applyProviderInstanceSettings(entries, { + providerInstances: {}, + providers: { codex: { enabled: false } } as never, + }); + + expect(entry?.enabled).toBe(false); + }); }); describe("deriveProviderInstanceEntries", () => { diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 3858cf7e0461..428bbe91317b 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -233,9 +233,10 @@ export function deriveProviderEntriesByEnvironment( * settings write, so picker visibility must follow settings rather than waiting * for probe reconciliation. * - * Non-default instances only exist through `providerInstances`; if one is - * absent there, its streamed snapshot is stale (for example immediately after - * deletion) and is treated as disabled. + * Only built-in default instances have a legacy `providers` entry. Every + * other instance exists through `providerInstances`; if it is absent there, + * its streamed snapshot is stale (for example immediately after deletion) + * and is treated as disabled. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, @@ -246,11 +247,16 @@ export function applyProviderInstanceSettings( >; return entries.map((entry) => { - const explicitInstance = settings.providerInstances?.[entry.instanceId]; + const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) + ? settings.providerInstances[entry.instanceId] + : undefined; + const legacyProvider = Object.hasOwn(legacyProviders, entry.driverKind) + ? legacyProviders[entry.driverKind] + : undefined; const enabled = explicitInstance ? resolveProviderInstanceEnabled(explicitInstance) - : entry.isDefault - ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled) + : entry.isDefault && legacyProvider + ? (legacyProvider.enabled ?? entry.enabled) : false; return enabled === entry.enabled ? entry : { ...entry, enabled }; }); From 9159b808d35a88e74fc91e11070f3270cdb321f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 13:03:27 -0700 Subject: [PATCH 02/29] feat(mobile): long-press file references for path and open actions (#9258) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5 --- apps/mobile/modules/t3-markdown-text/index.ts | 2 + .../t3-markdown-text/ios/T3MarkdownText.mm | 101 ++++++++++++++++-- .../t3-markdown-text/ios/T3MarkdownTextRun.h | 3 + .../t3-markdown-text/ios/T3MarkdownTextRun.mm | 72 ++++++++++++- .../src/MarkdownTextPrimitive.tsx | 6 ++ .../src/NativeMarkdownSelectableText.ios.tsx | 24 ++++- .../src/SelectableMarkdownText.ios.tsx | 76 +++++++------ .../src/SelectableMarkdownText.types.ts | 13 +++ .../src/T3MarkdownTextRunNativeComponent.ts | 6 ++ .../src/features/files/filePath.test.ts | 1 + apps/mobile/src/features/files/filePath.ts | 7 +- .../src/features/threads/ThreadFeed.tsx | 54 ++++++++-- .../src/features/threads/fileChipMenu.test.ts | 44 ++++++++ .../src/features/threads/fileChipMenu.ts | 49 +++++++++ .../src/native/SelectableMarkdownText.ios.tsx | 2 + .../src/native/SelectableMarkdownText.tsx | 2 + docs/user/composer.md | 3 +- 17 files changed, 411 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/src/features/threads/fileChipMenu.test.ts create mode 100644 apps/mobile/src/features/threads/fileChipMenu.ts diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts index 89bce5395c8c..81b5f13f28df 100644 --- a/apps/mobile/modules/t3-markdown-text/index.ts +++ b/apps/mobile/modules/t3-markdown-text/index.ts @@ -21,6 +21,8 @@ export { type MarkdownHighlightedToken, } from "./src/SelectableMarkdownText"; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 6fa61aab17e9..25f1e94c110f 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -198,6 +198,8 @@ @implementation T3MarkdownText { BOOL _suppressSelectionChange; NSMutableDictionary * _attachmentImages; NSMutableSet * _pendingAttachmentUris; + UILongPressGestureRecognizer *_longPressGestureRecognizer; + UITapGestureRecognizer *_pressGestureRecognizer; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -223,21 +225,24 @@ - (instancetype)initWithFrame:(CGRect)frame _textView.textContainerInset = UIEdgeInsetsZero; _textView.textContainer.lineFragmentPadding = 0; _textView.delegate = self; + // Chat text supports selection and contextual actions, but not drag-and-drop. + _textView.textDragInteraction.enabled = NO; + _textView.linkTextAttributes = @{}; // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. _textView.layoutManager.usesFontLeading = NO; [self addSubview:_textView]; - const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPressIfNecessary:)]; - longPressGestureRecognizer.delegate = self; + _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPressIfNecessary:)]; + _longPressGestureRecognizer.delegate = self; - const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handlePressIfNecessary:)]; - pressGestureRecognizer.delegate = self; - [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; + _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handlePressIfNecessary:)]; + _pressGestureRecognizer.delegate = self; + [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer]; - [_textView addGestureRecognizer:pressGestureRecognizer]; - [_textView addGestureRecognizer:longPressGestureRecognizer]; + [_textView addGestureRecognizer:_pressGestureRecognizer]; + [_textView addGestureRecognizer:_longPressGestureRecognizer]; } return self; @@ -312,6 +317,26 @@ - (void)drawRect:(CGRect)rect convertedAttrString, _state->getData().attachmentRanges, _attachmentImages); + NSUInteger runLocation = 0; + for (UIView *child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; + const NSRange runRange = NSMakeRange(runLocation, textChild.text.length); + runLocation = NSMaxRange(runRange); + if (![textChild hasContextMenu] || runRange.length == 0 || + NSMaxRange(runRange) > convertedAttrString.length) { + continue; + } + + NSURL *link = [NSURL URLWithString: + [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]]; + if (link != nil) { + [convertedAttrString addAttribute:NSLinkAttributeName value:link range:runRange]; + } + } [self loadAttachmentImages:_state->getData().attachmentRanges]; // Setting attributedText clears any active text selection, and re-assigning @@ -484,6 +509,18 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni return YES; } +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer +{ + if (gestureRecognizer != _longPressGestureRecognizer && + gestureRecognizer != _pressGestureRecognizer) { + return YES; + } + + const auto location = [self getLocationOfPress:gestureRecognizer]; + const auto child = [self getTouchChild:location]; + return ![child hasContextMenu]; +} + - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { return YES; @@ -507,6 +544,24 @@ - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView // MARK: - Touch handling +- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange +{ + NSUInteger location = 0; + for (UIView *child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; + const NSRange range = NSMakeRange(location, textChild.text.length); + if (NSIntersectionRange(range, characterRange).length > 0) { + return textChild; + } + location = NSMaxRange(range); + } + return nil; +} + - (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender { return [sender locationInView:_textView]; @@ -550,6 +605,10 @@ - (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender { + if (sender.state != UIGestureRecognizerStateBegan) { + return; + } + const auto location = [self getLocationOfPress:sender]; const auto child = [self getTouchChild:location]; @@ -560,6 +619,30 @@ - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender // MARK: - UITextViewDelegate +- (nullable UIAction *)textView:(UITextView *)textView + primaryActionForTextItem:(UITextItem *)textItem + defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0)) +{ + T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; + if (![child hasContextMenu]) { + return defaultAction; + } + + __weak T3MarkdownTextRun *weakChild = child; + return [UIAction actionWithHandler:^(__kindof UIAction *action) { + [weakChild onPress]; + }]; +} + +- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView + menuConfigurationForTextItem:(UITextItem *)textItem + defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0)) +{ + T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; + UIMenu *menu = [child contextMenu]; + return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu]; +} + - (void)textViewDidChangeSelection:(UITextView *)textView { if (_suppressSelectionChange) { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h index b8b406571106..a3b2b419135a 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h @@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *text; +- (nullable UIMenu *)contextMenu; +- (BOOL)hasContextMenu; +- (void)onContextMenuAction:(NSString *)actionIdentifier; - (void)onPress; - (void)onLongPress; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm index 4549084f03f6..d2de6884396f 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm @@ -15,8 +15,7 @@ @interface T3MarkdownTextRun () @implementation T3MarkdownTextRun { NSString * _text; - RCTBubblingEventBlock _onPress; - RCTBubblingEventBlock _onLongPress; + NSString * _contextMenuConfig; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -43,9 +42,78 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _text = text; } + if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) { + _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()]; + } + [super updateProps:props oldProps:oldProps]; } +- (BOOL)hasContextMenu +{ + return _contextMenuConfig.length > 0; +} + +- (nullable UIMenu *)contextMenu +{ + if (_contextMenuConfig.length == 0) { + return nil; + } + + NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if (![config isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSArray *actionConfigs = config[@"actions"]; + if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) { + return nil; + } + + NSMutableArray *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count]; + __weak T3MarkdownTextRun *weakSelf = self; + for (NSDictionary *actionConfig in actionConfigs) { + if (![actionConfig isKindOfClass:[NSDictionary class]]) { + continue; + } + NSString *actionIdentifier = actionConfig[@"id"]; + NSString *title = actionConfig[@"title"]; + if (![actionIdentifier isKindOfClass:[NSString class]] || + ![title isKindOfClass:[NSString class]]) { + continue; + } + + UIAction *action = [UIAction actionWithTitle:title + image:nil + identifier:actionIdentifier + handler:^(__kindof UIAction *selectedAction) { + [weakSelf onContextMenuAction:selectedAction.identifier]; + }]; + if ([actionConfig[@"disabled"] boolValue]) { + action.attributes = UIMenuElementAttributesDisabled; + } + [actions addObject:action]; + } + + if (actions.count == 0) { + return nil; + } + NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @""; + return [UIMenu menuWithTitle:title children:actions]; +} + +- (void)onContextMenuAction:(NSString *)actionIdentifier +{ + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{ + static_cast(self.tag), + actionIdentifier.UTF8String, + }); + } +} + - (void)onPress { if (_eventEmitter != nullptr) { std::dynamic_pointer_cast(_eventEmitter) diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 6ed7fecd2d31..2cd54b5c1ef2 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -24,8 +24,14 @@ export type SelectionChangeEvent = { nativeEvent: { target: number; start: number; end: number }; }; +export type ContextMenuActionEvent = { + nativeEvent: { target: number; actionIdentifier: string }; +}; + export type MarkdownTextPrimitiveProps = TextProps & { uiTextView?: boolean; + contextMenuConfig?: string; + onContextMenuAction?: (event: ContextMenuActionEvent) => void; /** * Fired when the native text selection changes. Only fires on iOS when * `uiTextView` is true. Note: fires on every selection-edge adjustment diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index 994c8ce2ed5a..ea4bd0f24882 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -1,9 +1,23 @@ +import { createContext, useContext } from "react"; import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; -import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; +import type { + MarkdownFileContextMenu, + NativeMarkdownTextStyle, +} from "./SelectableMarkdownText.types"; + +export interface MarkdownFileContextMenuHandlers { + readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction: (href: string, actionId: string) => void; +} + +/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */ +export const MarkdownFileContextMenuContext = createContext( + null, +); const EXTERNAL_LINK_PREFIX = "◉ "; const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; @@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: { readonly onLinkPress?: (href: string) => void; }) { const colorScheme = useColorScheme(); + const menu = useContext(MarkdownFileContextMenuContext); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { @@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: { > {keyedRuns.map(({ key, run, text }) => { const href = run.href; + const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) + : undefined + } > {text} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 188a45e07322..2a231c603584 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -9,7 +9,11 @@ import { nativeMarkdownWithPreservedSoftBreaks, } from "./nativeMarkdownText"; import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; -import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import { + MarkdownFileContextMenuContext, + NativeMarkdownSelectableText, + type MarkdownFileContextMenuHandlers, +} from "./NativeMarkdownSelectableText.ios"; import type { SelectableMarkdownSkill, SelectableMarkdownTextProps, @@ -38,6 +42,8 @@ export function SelectableMarkdownText({ highlightCode, preserveSoftBreaks = false, onLinkPress, + fileContextMenu, + onFileContextMenuAction, renderImage, marginTop = 0, marginBottom = 0, @@ -61,41 +67,51 @@ export function SelectableMarkdownText({ ); }, [markdown, preserveSoftBreaks, skills]); + const fileContextMenuHandlers = useMemo( + () => + fileContextMenu && onFileContextMenuAction + ? { fileContextMenu, onFileContextMenuAction } + : null, + [fileContextMenu, onFileContextMenuAction], + ); + return ( - {/* A percentage width here creates a cyclic intrinsic measurement inside + + {/* A percentage width here creates a cyclic intrinsic measurement inside shrink-to-fit containers such as user-message bubbles. Yoga then gives the native text node an unbounded second pass and the parent only clips the resulting single-line width instead of reflowing it. */} - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); - return ( - - {content} - - ); - })} - + return ( + + {content} + + ); + })} + + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 00260b0c4f27..50b1cccb6602 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -50,6 +50,17 @@ export interface MarkdownImageRequest { */ export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; +export interface MarkdownFileContextMenuAction { + readonly id: string; + readonly title: string; + readonly disabled?: boolean; +} + +export interface MarkdownFileContextMenu { + readonly title?: string; + readonly actions: ReadonlyArray; +} + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -57,6 +68,8 @@ export interface SelectableMarkdownTextProps { readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction?: (href: string, actionId: string) => void; readonly renderImage?: MarkdownImageRenderer; readonly marginTop?: number; readonly marginBottom?: number; diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts index 7f8fab8d8440..040e44bc18b5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -11,6 +11,10 @@ interface TargetedEvent { target: Int32; } +interface ContextMenuActionEvent extends TargetedEvent { + actionIdentifier: string; +} + type TextDecorationLine = "none" | "underline" | "line-through"; type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; @@ -42,8 +46,10 @@ interface NativeProps extends ViewProps { textDecorationColor?: ColorValue; textAlign?: WithDefault; shadowRadius?: WithDefault; + contextMenuConfig?: string; onPress?: BubblingEventHandler; onLongPress?: BubblingEventHandler; + onContextMenuAction?: BubblingEventHandler; } export default codegenNativeComponent("T3MarkdownTextRun", { diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index cd453b1d2c98..898cc4a16f5a 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -32,6 +32,7 @@ describe("resolveWorkspaceRelativeFilePath", () => { it("rejects paths outside the workspace", () => { expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); + expect(resolveWorkspaceRelativeFilePath("/repo", "/repo/../outside.txt")).toBeNull(); expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull(); }); }); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index f2d377de126f..2598b58d7c9b 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -88,7 +88,12 @@ export function resolveWorkspaceRelativeFilePath( return null; } - return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); + const relativePath = normalizedTarget.slice(normalizedRoot.length + 1); + // `/repo/../x` starts with the root but escapes it. + if (relativePath.split("/").includes("..")) { + return null; + } + return normalizeRelativePath(relativePath); } export function isVideoPreviewFile(path: string): boolean { diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index e515d6f72efd..1b7cb5f373ec 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -91,6 +91,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, @@ -176,6 +177,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout @@ -872,10 +874,17 @@ function ArtifactTemplateCard(props: { ); } +/** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ +interface MarkdownLinkHandlers { + readonly onLinkPress: (href: string) => void; + readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; + readonly onFileContextMenuAction: (href: string, actionId: string) => void; +} + const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly markdownStyles: MarkdownStyleSet; - readonly onLinkPress: (href: string) => void; + readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; @@ -904,7 +913,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { markdown={markdown} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ) : ( @@ -1465,7 +1474,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; - readonly onMarkdownLinkPress: (href: string) => void; + readonly markdownLinkHandlers: MarkdownLinkHandlers; readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; @@ -1573,7 +1582,7 @@ function renderFeedEntry( markdownStyles={styles} reviewCommentColors={props.reviewCommentColors} skills={props.skills} - onLinkPress={props.onMarkdownLinkPress} + linkHandlers={props.markdownLinkHandlers} renderImage={props.renderMarkdownImage} /> ) : null} @@ -1634,7 +1643,7 @@ function renderFeedEntry( ; - readonly onLinkPress: (href: string) => void; + readonly linkHandlers: MarkdownLinkHandlers; readonly renderImage: MarkdownImageRenderer; }) { const segments = parseReviewCommentMessageSegments(props.text); @@ -1717,7 +1726,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ); @@ -1759,7 +1768,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - onLinkPress={props.onLinkPress} + {...props.linkHandlers} renderImage={props.renderImage} /> ) : ( @@ -2171,6 +2180,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); + const markdownLinkHandlers = useMemo( + () => ({ + onLinkPress: onMarkdownLinkPress, + fileContextMenu: (href) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + return target ? fileChipMenu(target) : undefined; + }, + onFileContextMenuAction: (href, actionId) => { + const target = resolveFileChipTarget(href, props.workspaceRoot); + if (!target) return; + switch (actionId as FileChipAction) { + case "copy-full-path": + if (target.fullPath) copyTextWithHaptic(target.fullPath); + return; + case "copy-relative-path": + if (target.relativePath) copyTextWithHaptic(target.relativePath); + return; + case "open-file": + onMarkdownLinkPress(href); + return; + } + }, + }), + [onMarkdownLinkPress, props.workspaceRoot], + ); const renderMarkdownImage = useCallback( (image) => { const media = resolveMarkdownMediaPreview(image.href, { @@ -2696,7 +2730,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressPreview, onPressVideo, - onMarkdownLinkPress, + markdownLinkHandlers, renderMarkdownImage, renderViewedImage, iconSubtleColor, @@ -2726,7 +2760,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, userBubbleMaxWidth, onCopyWorkRow, - onMarkdownLinkPress, + markdownLinkHandlers, onPressPreview, onPressVideo, onToggleTurnFold, diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts new file mode 100644 index 000000000000..eb9bad3a4195 --- /dev/null +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; + +describe("resolveFileChipTarget", () => { + it("resolves a workspace-relative link to both paths", () => { + expect(resolveFileChipTarget("src/app.ts:12", "/repo")).toEqual({ + fullPath: "/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("keeps only the full path for a host file outside the workspace", () => { + expect(resolveFileChipTarget("/tmp/report.md", "/repo")).toEqual({ + fullPath: "/tmp/report.md", + }); + }); + + it("keeps only the relative path when the workspace root is unknown", () => { + expect(resolveFileChipTarget("src/app.ts", null)).toEqual({ relativePath: "src/app.ts" }); + }); + + it("ignores links that are not files or cannot be opened", () => { + expect(resolveFileChipTarget("https://example.com/app.ts", "/repo")).toBeNull(); + expect(resolveFileChipTarget("~/report.md", "/repo")).toBeNull(); + expect(resolveFileChipTarget("../other/file.ts", "/repo")).toBeNull(); + }); +}); + +describe("fileChipMenu", () => { + it("offers only the copies the target can satisfy", () => { + expect(fileChipMenu({ fullPath: "/tmp/report.md" })).toEqual({ + title: "/tmp/report.md", + actions: [ + { id: "copy-full-path", title: "Copy full path" }, + { id: "open-file", title: "Open in file viewer" }, + ], + }); + expect(fileChipMenu({ relativePath: "src/app.ts" }).actions.map(({ id }) => id)).toEqual([ + "copy-relative-path", + "open-file", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts new file mode 100644 index 000000000000..3630a62b3551 --- /dev/null +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -0,0 +1,49 @@ +import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; + +import { + isAbsolutePath, + resolveWorkspaceFilePath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; + +export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; + +export interface FileChipTarget { + /** The host path, when the link is absolute or the workspace root is known. */ + readonly fullPath?: string; + /** The path inside the workspace, when the link resolves there. */ + readonly relativePath?: string; +} + +/** Null when the link is not a file or resolves nowhere the feed can open, such as `~/x` or `../x`. */ +export function resolveFileChipTarget( + href: string, + workspaceRoot: string | null | undefined, +): FileChipTarget | null { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind !== "file") return null; + const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, presentation.path); + const fullPath = isAbsolutePath(presentation.path) + ? presentation.path + : workspaceRoot && relativePath + ? resolveWorkspaceFilePath(workspaceRoot, relativePath) + : undefined; + if (!fullPath && !relativePath) return null; + return { + ...(fullPath ? { fullPath } : {}), + ...(relativePath ? { relativePath } : {}), + }; +} + +/** The same actions the web file chip offers on right-click. Opening is what a tap does. */ +export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { + return { + title: target.fullPath ?? target.relativePath ?? "", + actions: [ + ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), + ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), + { id: "open-file", title: "Open in file viewer" }, + ], + }; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 7c2c037eed33..55c2f818b3d7 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, MarkdownImageRenderer, MarkdownImageRequest, NativeMarkdownTextStyle, diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 7ee4d21b1560..8b54df001aef 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownFileContextMenu, + MarkdownFileContextMenuAction, MarkdownImageRenderer, MarkdownImageRequest, NativeMarkdownTextStyle, diff --git a/docs/user/composer.md b/docs/user/composer.md index 326eb59ce0b0..eea5a658a337 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -107,7 +107,8 @@ path** and **Open in file viewer**. These actions are available in expanded prev On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file viewer from the same menu. Saving downloads a copy only when you request it; it does not change -how the video buffers during playback. +how the video buffers during playback. On iOS, touch and hold a file reference in a message to +copy its full or relative path or open it in the file viewer. Use Markdown image syntax to embed either kind of media: From 8d5b712de3cbd84118327808c403756e8894014a Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:44:30 +0300 Subject: [PATCH 03/29] fix(desktop): exclude opposite macOS pty prebuilds (#9240) --- scripts/build-desktop-artifact.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 9e6f1457c421..17c038979d0e 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -941,6 +941,19 @@ export const MAC_FILE_EXCLUSIONS = [ "!**/node_modules/node-pty/prebuilds/win32-*/**/*", "!**/node_modules/node-pty/third_party/conpty/**/*", ] as const; + +// node-pty publishes both Darwin prebuilds in one package. Single-architecture +// apps only need the native target; universal apps need both. An omitted arch +// preserves the existing common exclusions for callers that only inspect the +// generic platform config. +export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { + if (arch === undefined || arch === "universal") { + return [...MAC_FILE_EXCLUSIONS]; + } + + const unusedArch = arch === "arm64" ? "x64" : "arm64"; + return [...MAC_FILE_EXCLUSIONS, `!**/node_modules/node-pty/prebuilds/darwin-${unusedArch}/**/*`]; +} // Windows ships the server tree (bundle + node_modules) as a separate // resources/server.asar sidecar instead of loose files: the NSIS installer // then extracts a handful of large archives instead of thousands of small @@ -2428,13 +2441,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // sidecar staging skips the archive in that case, and listing a resource // whose source file was never written fails the electron-builder step. wslRuntimeBundled = false, + arch?: typeof BuildArch.Type, ) { const buildConfig: Record = { appId: DESKTOP_APP_ID, productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES], - files: [...DESKTOP_FILE_EXCLUSIONS, ...(platform === "mac" ? MAC_FILE_EXCLUSIONS : [])], + files: [ + ...DESKTOP_FILE_EXCLUSIONS, + ...(platform === "mac" ? resolveMacFileExclusions(arch) : []), + ], directories: { buildResources: "apps/desktop/resources", }, @@ -3502,6 +3519,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } : undefined, bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }), + options.arch, ), dependencies: stageDependencies, devDependencies: { From b57726ca842624b713572529109ac49b17c93fb3 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Wed, 2 Sep 2026 22:52:50 +0200 Subject: [PATCH 04/29] feat(web): add copy path button to diff headers (#2403) Co-authored-by: Julius Marminge Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/components/DiffFilePathCopyButton.tsx | 41 +++++++++++++++++ apps/web/src/components/DiffPanel.tsx | 4 ++ .../src/components/chat/MessageCopyButton.tsx | 44 ++++--------------- .../diffs/AnnotatableCodeView.test.tsx | 1 + .../components/diffs/AnnotatableCodeView.tsx | 5 +++ .../src/components/ui/anchoredCopyToast.ts | 33 ++++++++++++++ 6 files changed, 92 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/components/DiffFilePathCopyButton.tsx create mode 100644 apps/web/src/components/ui/anchoredCopyToast.ts diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx new file mode 100644 index 000000000000..49b00ed89b74 --- /dev/null +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -0,0 +1,41 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useRef } from "react"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { + ANCHORED_COPY_TOAST_TIMEOUT_MS, + showAnchoredCopyErrorToast, + showAnchoredCopySuccessToast, +} from "./ui/anchoredCopyToast"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { + const ref = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => showAnchoredCopySuccessToast(ref), + onError: (error) => showAnchoredCopyErrorToast(ref, error), + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + }); + + return ( + + copyToClipboard(filePath, undefined)} + /> + } + > + {isCopied ? : } + + +

{isCopied ? "Copied" : "Copy path"}

+
+
+ ); +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 0adc58b236c2..acdc54bb2f4c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -46,6 +46,7 @@ import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; +import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; @@ -920,6 +921,9 @@ export default function DiffPanel({ sectionId={reviewSectionId} sectionTitle={reviewSectionTitle} composerDraftTarget={composerDraftTarget} + renderHeaderFilenameSuffix={(fileDiff) => ( + + )} renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { const filePath = resolveFileDiffPath(fileDiff); return ( diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index e6a6a491bb33..86e1a5d3c8d5 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -3,41 +3,13 @@ import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; -import { anchoredToastManager } from "../ui/toast"; +import { + ANCHORED_COPY_TOAST_TIMEOUT_MS, + showAnchoredCopyErrorToast, + showAnchoredCopySuccessToast, +} from "../ui/anchoredCopyToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -const ANCHORED_TOAST_TIMEOUT_MS = 1000; -const onCopy = (ref: React.RefObject) => { - if (ref.current) { - anchoredToastManager.add({ - data: { - tooltipStyle: true, - }, - positionerProps: { - anchor: ref.current, - }, - timeout: ANCHORED_TOAST_TIMEOUT_MS, - title: "Copied!", - }); - } -}; - -const onCopyError = (ref: React.RefObject, error: Error) => { - if (ref.current) { - anchoredToastManager.add({ - data: { - tooltipStyle: true, - }, - positionerProps: { - anchor: ref.current, - }, - timeout: ANCHORED_TOAST_TIMEOUT_MS, - title: "Failed to copy", - description: error.message, - }); - } -}; - export const MessageCopyButton = memo(function MessageCopyButton({ text, size = "xs", @@ -51,9 +23,9 @@ export const MessageCopyButton = memo(function MessageCopyButton({ }) { const ref = useRef(null); const { copyToClipboard, isCopied } = useCopyToClipboard({ - onCopy: () => onCopy(ref), - onError: (error: Error) => onCopyError(ref, error), - timeout: ANCHORED_TOAST_TIMEOUT_MS, + onCopy: () => showAnchoredCopySuccessToast(ref), + onError: (error: Error) => showAnchoredCopyErrorToast(ref, error), + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, }); return ( diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx index 878ac2304098..78d9b436d79c 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx @@ -46,6 +46,7 @@ describe("AnnotatableCodeView", () => { composerDraftTarget={"draft-test" as never} options={{}} renderHeaderPrefix={() => null} + renderHeaderFilenameSuffix={() => null} />, ); diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index f48e5b5eaf21..b8ace2340557 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -86,6 +86,7 @@ interface AnnotatableCodeViewProps { options: StyledDiffCodeViewOptions; viewerRef?: Ref; className?: string; + renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode; renderHeaderPrefix: ( fileDiff: FileDiffMetadata, fileKey: string, @@ -106,6 +107,7 @@ export function AnnotatableCodeView({ options, viewerRef, className, + renderHeaderFilenameSuffix, renderHeaderPrefix, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); @@ -252,6 +254,9 @@ export function AnnotatableCodeView({ enableLineSelection: !hasOpenComment, onGutterUtilityClick: beginComment, }} + renderHeaderFilenameSuffix={(item) => + item.type === "diff" ? renderHeaderFilenameSuffix(item.fileDiff) : null + } renderHeaderPrefix={(item) => item.type === "diff" ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true) diff --git a/apps/web/src/components/ui/anchoredCopyToast.ts b/apps/web/src/components/ui/anchoredCopyToast.ts new file mode 100644 index 000000000000..df1ac579c89c --- /dev/null +++ b/apps/web/src/components/ui/anchoredCopyToast.ts @@ -0,0 +1,33 @@ +import type { RefObject } from "react"; +import { anchoredToastManager } from "./toast"; + +export const ANCHORED_COPY_TOAST_TIMEOUT_MS = 1000; + +export function showAnchoredCopySuccessToast(ref: RefObject) { + if (!ref.current) return; + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + title: "Copied!", + }); +} + +export function showAnchoredCopyErrorToast(ref: RefObject, error: Error) { + if (!ref.current) return; + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + title: "Failed to copy", + description: error.message, + }); +} From f90e2f2bd26e22b77ccf781cccdf95afd3c3ac1c Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:13:46 -0400 Subject: [PATCH 05/29] fix(server): subscribe before provider settings watcher (#9271) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .../Layers/ProviderInstanceRegistryHydration.ts | 9 +++++---- .../src/provider/Layers/ProviderRegistry.test.ts | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0fd88b4262a6..31c9fcbe871f 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -31,7 +31,7 @@ * 1. Read the current `ServerSettings` once and use it to seed the * registry's initial state via `ProviderInstanceRegistryMutableLayer`. * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that - * subscribes to `ServerSettingsService.streamChanges` and calls + * acquires `ServerSettingsService.subscribeChanges` and calls * `ProviderInstanceRegistryMutator.reconcile` on every emission. * * Failures inside the watcher are logged and swallowed so a single bad @@ -118,7 +118,8 @@ const SettingsWatcherLive = Layer.effectDiscard( Effect.gen(function* () { const mutator = yield* ProviderInstanceRegistryMutator; const serverSettings = yield* ServerSettingsService; - yield* serverSettings.streamChanges.pipe( + const settingsChanges = yield* serverSettings.subscribeChanges; + yield* settingsChanges.pipe( Stream.runForEach((next) => mutator .reconcile(deriveProviderInstanceConfigMap(next)) @@ -141,8 +142,8 @@ const SettingsWatcherLive = Layer.effectDiscard( * - `ProviderInstanceRegistryMutableLayer` produces the registry + * mutator from the initial config map. Its scope owns every * per-instance child scope created during reconcile. - * - `SettingsWatcherLive` consumes the mutator and runs a daemon fiber - * in the same scope. + * - `SettingsWatcherLive` consumes the mutator, acquires its settings + * subscription before forking, and runs a daemon fiber in the same scope. * * Composing via `Layer.provideMerge` makes the watcher's deps available * from the mutable layer while still surfacing the registry as an output. diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cc9fc58ccc41..08650758c308 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1944,7 +1944,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const firstMissing = `t3code_codex_first_`; const secondMissing = `t3code_codex_second_`; const spawnedCommands: Array = []; - const serverSettings = yield* makeMutableServerSettingsService( + const allowLazySettingsStream = yield* Deferred.make(); + const mutableServerSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { providers: { @@ -1957,6 +1958,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ), ); + const serverSettings = { + ...mutableServerSettings, + streamChanges: Stream.unwrap( + Deferred.await(allowLazySettingsStream).pipe( + Effect.as(mutableServerSettings.streamChanges), + ), + ), + } satisfies ServerSettingsModule.ServerSettingsService["Service"]; const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( @@ -2016,7 +2025,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(spawnedCommands, [firstMissing]); // Drive a settings change. The Hydration layer's - // `SettingsWatcherLive` consumes this via `streamChanges`, + // `SettingsWatcherLive` consumes this via `subscribeChanges`, // calls `reconcile`, which rebuilds the codex instance (the // envelope changed because `binaryPath` differs → `entryEqual` // is false). The registry's `Stream.runForEach( @@ -2028,6 +2037,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te codex: { enabled: true, binaryPath: secondMissing }, }, }); + // Start the lazy stream only after publishing. A watcher that did + // not subscribe before forking has already lost this update. + yield* Deferred.succeed(allowLazySettingsStream, undefined); // Poll until the injected process boundary observes the new // executable. This verifies the public settings-to-probe behavior From 62c68dc41f9d9d7ecaf5193a6a8d997ebf671a61 Mon Sep 17 00:00:00 2001 From: Nick Anisimov Date: Thu, 3 Sep 2026 01:18:46 +0400 Subject: [PATCH 06/29] fix(mobile): show filled filter icon on Android when filters are active (#9217) --- apps/mobile/src/components/AppSymbol.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 13d9e6208570..0c2042218cda 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -39,6 +39,7 @@ import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; import IconEye from "@tabler/icons-react-native/IconEye"; import IconFileText from "@tabler/icons-react-native/IconFileText"; import IconFilter from "@tabler/icons-react-native/IconFilter"; +import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; import IconFolder from "@tabler/icons-react-native/IconFolder"; import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus"; @@ -130,7 +131,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "info.circle": IconInfoCircle, link: IconLink, "line.3.horizontal.decrease.circle": IconFilter, - "line.3.horizontal.decrease.circle.fill": IconFilter, + "line.3.horizontal.decrease.circle.fill": IconFilterFilled, magnifyingglass: IconSearch, paintbrush: IconPalette, "person.crop.circle": IconUserCircle, From 46b5c66406b9942589d7e9132beeafda2434f113 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 17:21:26 -0400 Subject: [PATCH 07/29] fix(chat): show single tool calls without summaries (#9267) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/mobile/src/lib/threadActivity.test.ts | 31 +++++++++-- apps/mobile/src/lib/threadActivity.ts | 25 +++++++-- .../chat/MessagesTimeline.logic.test.ts | 55 ++++++++++--------- .../components/chat/MessagesTimeline.logic.ts | 29 ++++++++-- .../src/components/chat/MessagesTimeline.tsx | 2 +- 5 files changed, 104 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 32d12b074851..47bfe6755db6 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -870,6 +870,7 @@ describe("buildThreadFeed", () => { status: undefined, displayName: "Click in the preview browser", liveDisplayName: "Clicking in the preview browser", + settledDisplayName: "Clicked in the preview browser", icon: "browser", }, { @@ -880,11 +881,12 @@ describe("buildThreadFeed", () => { status: undefined, displayName: "Get delegated task status", liveDisplayName: "Getting delegated task status", + settledDisplayName: "Got delegated task status", icon: "t3-code", }, ])( "uses friendly row and running labels from $source", - ({ label, title, item, status, displayName, liveDisplayName, icon }) => { + ({ label, title, item, status, displayName, liveDisplayName, settledDisplayName, icon }) => { const turnId = TurnId.make("turn-friendly-mcp"); const rawCommand = "node mcp-call.js"; const rawDetail = '{"provider":"raw MCP output"}'; @@ -949,6 +951,23 @@ describe("buildThreadFeed", () => { live: true, }, ]); + if (settledDisplayName) { + const settledRows = deriveThreadFeedPresentation( + feed, + { + ...thread.latestTurn!, + state: "completed", + completedAt: "2026-04-01T00:00:03.000Z", + }, + new Set([turnId]), + new Set(), + ); + expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({ + summary: settledDisplayName, + summaryToolIcon: icon, + live: false, + }); + } }, ); @@ -1041,7 +1060,7 @@ describe("buildThreadFeed", () => { hasFailure: true, }, ])( - "keeps a browser group expanded as its action changes from active to $status", + "uses the browser call label once its action settles as $status", ({ status, displayName, detail, hasFailure }) => { const turnId = TurnId.make("turn-preview-lifecycle"); const toolCallId = "preview-click"; @@ -1176,7 +1195,7 @@ describe("buildThreadFeed", () => { groupId, hiddenCount: 1, expanded: true, - summary: "Used browser 1 time", + summary: displayName, summaryKind: "browser", hasFailure, live: false, @@ -1762,7 +1781,11 @@ describe("buildThreadFeed", () => { const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ { live: false, shimmer: false }, - { live: false, shimmer: false }, + { + live: false, + shimmer: false, + summary: lifecycleStatus === "inProgress" ? "printf done" : command, + }, ]); const completedRows = deriveThreadFeedPresentation( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 16f533d8b4cc..c98cb40289ee 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -21,6 +21,7 @@ import { omitSupersededLifecycleMarkers, resolveWorkEntryToolPresentation, summarizeToolGroup, + toolGroupAction, toolGroupSummaryKind, type ToolGroupSummaryKind, } from "@t3tools/client-runtime/work-log/presentation"; @@ -837,6 +838,13 @@ function workEntryHeading(workEntry: WorkLogEntry): string { return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } +function singleToolCallLabel(activity: ThreadFeedActivity): string { + const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed"); + if (presentation) return presentation.displayName; + const command = activity.workEntry.command?.trim(); + return command || activity.summary; +} + function asRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } @@ -1518,14 +1526,23 @@ function appendToolGroupRows( const active = latestActiveActivity !== undefined; const live = activeTail || active; const latestActivity = latestActiveActivity ?? activities.at(-1)!; + const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live ? liveToolActivitySummary(latestActivity, active) - : activities.length === 1 && !activities[0]!.toolLike - ? activities[0]!.workEntry.label - : summarizeToolGroup(activities.map((activity) => activity.workEntry)); + : singleActivity !== null && + singleActivity.toolLike && + toolGroupAction(singleActivity.workEntry) !== "edit" + ? singleToolCallLabel(singleActivity) + : singleActivity !== null && !singleActivity.toolLike + ? singleActivity.workEntry.label + : summarizeToolGroup(activities.map((activity) => activity.workEntry)); const summaryToolIcon = live ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon - : undefined; + : singleActivity !== null && + singleActivity.toolLike && + toolGroupAction(singleActivity.workEntry) !== "edit" + ? resolveWorkEntryToolPresentation(singleActivity.workEntry, "completed")?.icon + : undefined; result.push({ type: "work-toggle", id: `${live ? "work-live" : "work-toggle"}:${groupId}`, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 9baaa14395df..08723c7b9fe9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -152,30 +152,35 @@ describe("work entry labels", () => { }, ); - it("gives a completed browser group its own count and summary icon category", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "browser-entry", - kind: "work", - createdAt: entry.createdAt, - entry: { - ...entry, - itemType: "mcp_tool_call", - toolLifecycleStatus: "completed", - toolData: { server: "t3-code", tool: "preview_click" }, + it.each([ + ["preview_click", "Clicked in the preview browser", "browser"], + ["task_status", "Got delegated task status", "t3-code"], + ] as const)( + "uses the completed %s call presentation for a settled legacy tool", + (tool, summary, summaryToolIcon) => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "browser-entry", + kind: "work", + createdAt: entry.createdAt, + entry: { + ...entry, + itemType: "mcp_tool_call", + toolData: { server: "t3-code", tool }, + }, }, - }, - ], - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - expect(rows).toMatchObject([ - { kind: "work-toggle", summary: "Used browser 1 time", summaryKind: "browser" }, - ]); - }); + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + expect(rows).toMatchObject([ + { kind: "work-toggle", hiddenCount: 1, summary, summaryToolIcon }, + ]); + }, + ); }); describe("shouldPreserveAssistantLineBreaks", () => { @@ -1126,7 +1131,7 @@ describe("deriveMessagesTimelineRows", () => { }); }); - it("summarizes a tool run after commentary starts a new run", () => { + it("labels a single completed tool call without summarizing it", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -1189,7 +1194,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]); expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ hiddenCount: 1, - summary: "Ran 1 command", + summary: "rg toolCall", }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7cea3803827d..60ec9a56f480 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -6,6 +6,7 @@ import { omitSupersededLifecycleMarkers, resolveWorkEntryToolPresentation, summarizeToolGroup, + toolGroupAction, toolGroupSummaryKind, type ToolGroupSummaryKind, } from "@t3tools/client-runtime/work-log/presentation"; @@ -34,6 +35,15 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; export const TIMELINE_CONTENT_MAX_WIDTH = 768; export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +function singleToolCallLabel(entry: WorkLogEntry): string { + const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); + if (toolPresentation) return toolPresentation.displayName; + const command = entry.command?.trim(); + if (command) return command; + const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label); + return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; +} + export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) { const toolPresentation = resolveWorkEntryToolPresentation(entry); if (toolPresentation) return toolPresentation.displayName; @@ -295,6 +305,7 @@ export type MessagesTimelineRow = expanded: boolean; summary: string; summaryKind: ToolGroupSummaryKind; + summaryToolIcon?: "browser" | "t3-code"; hasFailure: boolean; } | { @@ -840,6 +851,15 @@ export function deriveMessagesTimelineRows(input: { const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike); + const singleEntry = + visibleGroupedEntries.length === 1 ? (visibleGroupedEntries[0] ?? null) : null; + const usesSingleToolCallLabel = + singleEntry !== null && + workLogEntryIsToolLike(singleEntry) && + toolGroupAction(singleEntry) !== "edit"; + const summaryToolIcon = usesSingleToolCallLabel + ? resolveWorkEntryToolPresentation(singleEntry, "completed")?.icon + : undefined; nextRows.push({ kind: "work-toggle", id: `work-toggle:${timelineEntry.id}`, @@ -847,12 +867,13 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: visibleGroupedEntries.length, expanded, - summary: - visibleGroupedEntries.length === 1 && - !workLogEntryIsToolLike(visibleGroupedEntries[0]!) - ? visibleGroupedEntries[0]!.label + summary: usesSingleToolCallLabel + ? singleToolCallLabel(singleEntry) + : singleEntry !== null && !workLogEntryIsToolLike(singleEntry) + ? singleEntry.label : summarizeToolGroup(visibleGroupedEntries), summaryKind, + ...(summaryToolIcon ? { summaryToolIcon } : {}), hasFailure: latestToolEntry !== undefined && workEntryDisplayIndicatesToolFailure(latestToolEntry), diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index deb13df8e7f4..167eb700f849 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1851,7 +1851,7 @@ function WorkGroupToggleTimelineRow({ > From 5a9b56291f82b9269346053594d8b5dfca736976 Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Thu, 3 Sep 2026 02:54:24 +0530 Subject: [PATCH 08/29] fix(web): warn when shared settings have no target environment (#9207) --- apps/web/src/hooks/useSettings.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index a04c514fb458..6eb571f70aac 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -377,24 +377,31 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0) { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); - if (environmentId && Object.keys(localPatch).length > 0) { - void persistServerSettings({ - environmentId, - input: { patch: localPatch }, - }); - } else { - // Dropping the write silently leaves the control looking saved. + // Dropping the write silently leaves the control looking saved. + const warnUnsaved = () => toastManager.add({ type: "warning", title: "Setting not saved", description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, }); + if (Object.keys(localPatch).length > 0) { + if (environmentId) { + void persistServerSettings({ + environmentId, + input: { patch: localPatch }, + }); + } else { + warnUnsaved(); + } } if (Object.keys(sharedPatch).length > 0) { const targets = new Set(connectedEnvironmentIds); if (environmentId) { targets.add(environmentId); } + if (targets.size === 0) { + warnUnsaved(); + } for (const targetId of targets) { void persistServerSettings({ environmentId: targetId, From 28ddaf75917140e5e4355d4386bc5d14d9dad7b6 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 17:26:34 -0400 Subject: [PATCH 09/29] fix(web): confirm closing agent-controlled browsers (#9272) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../web/src/components/ChatView.logic.test.ts | 39 ++++++++++ apps/web/src/components/ChatView.logic.ts | 25 ++++++ apps/web/src/components/ChatView.tsx | 77 ++++++++++++++----- 3 files changed, 121 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e23febd1e07a..290e3435abca 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,9 +10,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import type { Thread, ThreadShell } from "../types"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; +import type { RightPanelSurface } from "../rightPanelStore"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, @@ -44,6 +46,43 @@ import { toolGroupConsumesUpwardNavigation, } from "./ChatView.logic"; +describe("agent browser close confirmation", () => { + const surfaces = [ + { id: "browser:one", kind: "preview", resourceId: "tab-1" }, + { id: "browser:two", kind: "preview", resourceId: "tab-2" }, + { id: "diff", kind: "diff" }, + ] satisfies RightPanelSurface[]; + + it("only warns for browsers under active agent control", () => { + expect( + agentControlledBrowserCloseConfirmation(surfaces, { + "tab-1": { controller: "none" }, + "tab-2": { controller: "human" }, + }), + ).toBeNull(); + + expect( + agentControlledBrowserCloseConfirmation([surfaces[0]!], { + "tab-1": { controller: "agent" }, + }), + ).toBe( + [ + "Close browser while the agent is using it?", + "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", + ].join("\n"), + ); + }); + + it("counts every agent-controlled browser in a bulk close", () => { + expect( + agentControlledBrowserCloseConfirmation(surfaces, { + "tab-1": { controller: "agent" }, + "tab-2": { controller: "agent" }, + }), + ).toContain("Close 2 browsers"); + }); +}); + describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 20620b65e624..e9a18d016877 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,6 +45,8 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; +import type { DesktopPreviewOverlay } from "../previewStateStore"; +import type { RightPanelSurface } from "../rightPanelStore"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -53,6 +55,29 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function agentControlledBrowserCloseConfirmation( + surfaces: readonly RightPanelSurface[], + desktopByTabId: Readonly | undefined>>, +): string | null { + const activeBrowserCount = surfaces.filter( + (surface) => + surface.kind === "preview" && + surface.resourceId !== null && + desktopByTabId[surface.resourceId]?.controller === "agent", + ).length; + if (activeBrowserCount === 0) return null; + if (activeBrowserCount === 1) { + return [ + "Close browser while the agent is using it?", + "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", + ].join("\n"); + } + return [ + `Close ${activeBrowserCount} browsers while the agent is using them?`, + "The agent is actively controlling these browsers. Closing them may interrupt the current browser actions.", + ].join("\n"); +} + export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 868434446912..305773955d0e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -343,6 +343,7 @@ import { } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -3872,6 +3873,27 @@ function ChatViewContent(props: ChatViewProps) { storeCloseTerminal, ], ); + const closeAfterAgentBrowserConfirmation = useCallback( + (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => { + const message = agentControlledBrowserCloseConfirmation( + surfaces, + activePreviewState.desktopByTabId, + ); + if (!message) { + closeSurfaces(); + return; + } + const localApi = readLocalApi(); + if (!localApi) return; + void localApi.dialogs.confirm(message, { variant: "destructive" }).then( + (confirmed) => { + if (confirmed) closeSurfaces(); + }, + () => undefined, + ); + }, + [activePreviewState.desktopByTabId], + ); const syncActivePreviewSurface = useCallback(() => { if (!activeThreadRef) return; const nextActiveSurface = selectActiveRightPanelSurface( @@ -3882,14 +3904,26 @@ function ChatViewContent(props: ChatViewProps) { setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); } }, [activeThreadRef]); + const finishRightPanelSurfaceClose = useCallback( + (surfaces: readonly RightPanelSurface[]) => { + if (!activeThreadRef) return; + cleanupRightPanelSurfaces(surfaces); + const store = useRightPanelStore.getState(); + for (const surface of surfaces) { + store.closeSurface(activeThreadRef, surface.id); + } + syncActivePreviewSurface(); + }, + [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + ); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - const finishClose = () => { - cleanupRightPanelSurfaces([surface]); - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); - syncActivePreviewSurface(); - }; + const finishClose = () => finishRightPanelSurfaceClose([surface]); + if (surface.kind === "preview") { + closeAfterAgentBrowserConfirmation([surface], finishClose); + return; + } if (surface.kind !== "terminal") { finishClose(); return; @@ -3909,23 +3943,22 @@ function ChatViewContent(props: ChatViewProps) { [ activeThreadRef, activeTerminalLabelsById, - cleanupRightPanelSurfaces, - syncActivePreviewSurface, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, ], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); - cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id); - syncActivePreviewSurface(); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); }, [ activeThreadRef, - cleanupRightPanelSurfaces, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, rightPanelState.surfaces, - syncActivePreviewSurface, ], ); const closeRightPanelSurfacesToRight = useCallback( @@ -3934,22 +3967,26 @@ function ChatViewContent(props: ChatViewProps) { const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); - cleanupRightPanelSurfaces(surfaces); - useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id); - syncActivePreviewSurface(); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); }, [ activeThreadRef, - cleanupRightPanelSurfaces, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, rightPanelState.surfaces, - syncActivePreviewSurface, ], ); const closeAllRightPanelSurfaces = useCallback(() => { if (!activeThreadRef) return; - cleanupRightPanelSurfaces(rightPanelState.surfaces); - useRightPanelStore.getState().closeAllSurfaces(activeThreadRef); - }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); + const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces); + closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose); + }, [ + activeThreadRef, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, + rightPanelState.surfaces, + ]); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( From 134d51096ea0d00a53a499e8f0c87e31fafb0006 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 14:56:45 -0700 Subject: [PATCH 10/29] feat(desktop): browser profiles for the preview browser (#7254) Co-authored-by: Claude Opus 5 (1M context) --- apps/desktop/src/ipc/methods/preview.test.ts | 44 ++- apps/desktop/src/ipc/methods/preview.ts | 73 +++- apps/desktop/src/preload.ts | 10 +- .../src/preview/BrowserSession.test.ts | 61 +++ apps/desktop/src/preview/BrowserSession.ts | 94 ++++- apps/desktop/src/preview/Manager.ts | 68 ++-- .../settings/DesktopClientSettings.test.ts | 2 + apps/server/src/preview/Manager.test.ts | 31 ++ apps/server/src/preview/Manager.ts | 19 +- apps/web/src/browser/ElectronBrowserHost.tsx | 1 + apps/web/src/browser/HostedBrowserWebview.tsx | 19 +- apps/web/src/browser/browserDefaults.test.ts | 43 +++ apps/web/src/browser/browserDefaults.ts | 49 ++- apps/web/src/browser/openFileInPreview.ts | 17 +- .../browser/previewWebviewConfigState.test.ts | 19 +- .../src/browser/previewWebviewConfigState.ts | 42 ++- apps/web/src/components/ChatView.tsx | 21 +- .../src/components/RightPanelTabs.test.tsx | 10 + apps/web/src/components/RightPanelTabs.tsx | 74 +++- .../components/preview/PreviewChromeRow.tsx | 8 + .../components/preview/PreviewMoreMenu.tsx | 55 ++- .../components/preview/PreviewView.test.tsx | 34 +- .../src/components/preview/PreviewView.tsx | 41 ++ .../preview/addBrowserSurface.test.ts | 20 + .../components/preview/addBrowserSurface.ts | 3 + .../preview/openPreviewSession.test.ts | 3 + .../components/preview/openPreviewSession.ts | 14 +- .../preview/openTerminalLinkInPreview.test.ts | 60 ++- .../preview/openTerminalLinkInPreview.ts | 15 +- .../IntegrationsSettings.logic.test.ts | 74 ++++ .../settings/IntegrationsSettings.tsx | 352 +++++++++++++++++- .../src/components/settings/settingsSearch.ts | 12 + apps/web/src/components/ui/menu.tsx | 7 +- apps/web/src/routes/_chat.pull-requests.tsx | 1 + packages/contracts/src/browserProfile.test.ts | 111 ++++++ packages/contracts/src/browserProfile.ts | 99 +++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 23 +- packages/contracts/src/preview.ts | 9 + packages/contracts/src/settings.ts | 15 + 40 files changed, 1535 insertions(+), 119 deletions(-) create mode 100644 apps/web/src/browser/browserDefaults.test.ts create mode 100644 apps/web/src/components/settings/IntegrationsSettings.logic.test.ts create mode 100644 packages/contracts/src/browserProfile.test.ts create mode 100644 packages/contracts/src/browserProfile.ts diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index e7770dc629dd..68ff5dbfef9b 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -1,5 +1,9 @@ import { it as effectIt } from "@effect/vitest"; -import { PreviewAutomationStatus } from "@t3tools/contracts"; +import { + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, + PreviewAutomationStatus, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -38,6 +42,44 @@ describe("preview IPC methods", () => { expect(fromPartition).not.toHaveBeenCalled(); }); + it("derives distinct partition scopes when identifiers contain the delimiter", () => { + const first = PreviewIpc.resolvePartitionScope("a", "b::c"); + const second = PreviewIpc.resolvePartitionScope("a::b", "c"); + + expect(first).toEqual({ scope: '["a","b::c"]', persistent: true, namespace: "profile" }); + expect(second).toEqual({ scope: '["a::b","c"]', persistent: true, namespace: "profile" }); + expect(first.scope).not.toBe(second.scope); + }); + + it("preserves lone surrogates without collapsing them to replacement characters", () => { + const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800"); + const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00"); + const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�"); + + expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]'); + expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]'); + expect(highSurrogate.scope).not.toBe(lowSurrogate.scope); + expect(highSurrogate.scope).not.toBe(replacement.scope); + expect(lowSurrogate.scope).not.toBe(replacement.scope); + }); + + it("keeps the legacy default partition scope and incognito persistence", () => { + expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({ + scope: "environment::legacy", + persistent: true, + }); + expect( + PreviewIpc.resolvePartitionScope("environment::legacy", DEFAULT_BROWSER_PROFILE_ID), + ).toEqual({ scope: "environment::legacy", persistent: true }); + expect( + PreviewIpc.resolvePartitionScope("environment::legacy", INCOGNITO_BROWSER_PROFILE_ID), + ).toEqual({ + scope: '["environment::legacy","incognito"]', + persistent: false, + namespace: "profile", + }); + }); + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => Effect.map( PreviewIpc.registerWebview diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5229d36c31f1..8a77770deb1e 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -16,11 +16,14 @@ import { DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, DesktopPreviewSetColorSchemeInputSchema, + DesktopPreviewClearDataInputSchema, DesktopPreviewCreateTabInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod( export const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, - payload: Schema.Void, + payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { + handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.clearCookies(); + yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId)); }), }); export const clearCache = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, - payload: Schema.Void, + payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.clearCache(); + yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId)); }), }); +/** + * Partition scope for an (environment, profile) pair. + * + * The default profile keeps the bare environment id it used before profiles + * existed, so upgrading does not strand anyone's existing logins in an + * orphaned partition. Incognito derives a non-persistent partition. + */ +export function resolvePartitionScope( + environmentId: string, + profileId: string | undefined, +): { + readonly scope: string; + readonly persistent: boolean; + readonly namespace?: "profile"; +} { + if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { + return { scope: environmentId, persistent: true }; + } + // JSON's tuple framing is injective for strings, including lone UTF-16 + // surrogates (which it escapes). URI encoding throws on those supported ids, + // while replacing them with U+FFFD would collapse distinct identities. + return { + scope: JSON.stringify([environmentId, profileId]), + persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, + namespace: "profile" as const, + }; +} + +/** + * Clearing without a profile keeps the historical "everything" behaviour for + * an explicit all-profiles action; naming a profile confines it to that + * profile's partition so one profile's sign-out cannot reach the others. + */ +const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* ( + manager: PreviewManager.PreviewManager["Service"], + environmentId: string, + profileId: string | undefined, +) { + if (profileId === undefined) return undefined; + const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); + // Loading the session is what puts the partition in the map the clear walks. + // Deriving the partition string alone leaves nothing to match, so clearing a + // profile with no tab open this run — after a restart, or when deleting a + // profile — would report success and delete nothing. + yield* manager.getBrowserSession(scope, persistent, namespace); + return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; +}); + export const getPreviewConfig = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, payload: DesktopPreviewConfigInputSchema, result: DesktopPreviewWebviewConfigSchema, - handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { + handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.getBrowserSession(environmentId); + const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); + // Creating the session first is what installs the UA rewrite and permission + // handlers; a guest that attached to an untouched partition would run with + // Electron's default UA and Chromium's default permission behaviour. + yield* manager.getBrowserSession(scope, persistent, namespace); return { - partition: yield* manager.getBrowserPartition(environmentId), + partition: yield* manager.getBrowserPartition(scope, persistent, namespace), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, }; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3e181e2ca698..b91aa5624dc2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), - clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), - clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), - getPreviewConfig: (environmentId) => - ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), + clearCookies: (environmentId, profileId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), + clearCache: (environmentId, profileId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }), + getPreviewConfig: (environmentId, profileId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }), setAnnotationTheme: (theme) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 50798de916e0..ff22f3dd2272 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -63,6 +63,45 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these + // two supported ids would hash to one partition and share every cookie. + const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); + const replacementChar = yield* browserSessions.getPartition("p\ufffd"); + assert.notStrictEqual(loneSurrogate, replacementChar); + + // The escape can't be forged with a literal backslash either. + const literal = yield* browserSessions.getPartition("p\\ud800"); + assert.notStrictEqual(literal, loneSurrogate); + + // And a well-formed scope still lands on its historical partition. + assert.strictEqual( + yield* browserSessions.getPartition("scope-a"), + "persist:t3code-preview-f051bb2c68cb7b2fe969", + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + // These share the same scope string: default environment `a::b`, and + // environment `a` with nondefault profile `b`. + const legacyDefault = yield* browserSessions.getPartition("a::b"); + const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile"); + + assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e"); + assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e"); + assert.notStrictEqual(nondefaultProfile, legacyDefault); + assert.isTrue(browserSessions.isPartition(legacyDefault)); + assert.isTrue(browserSessions.isPartition(nondefaultProfile)); + }).pipe(Effect.provide(layer)), + ); + it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -192,6 +231,28 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("clears a partition whose session has not been opened yet", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-untouched"); + + // Deriving the partition string does not create the session, and the + // clear only walks sessions it already holds. Without loading it first + // this reports success and deletes nothing — which is what a user + // clearing a profile after a restart would get. + assert.isUndefined(sessions.get(partition)); + yield* browserSessions.clearCookies([partition]); + assert.isUndefined(sessions.get(partition)); + + yield* browserSessions.getSession("scope-untouched"); + yield* browserSessions.clearCookies([partition]); + + const created = sessions.get(partition); + assert.isDefined(created); + assert.strictEqual(created.clearStorageData.mock.calls.length, 1); + }).pipe(Effect.provide(layer)), + ); + it.effect("correlates clear failures while still attempting every session", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 784afe019edf..7f3c9ec5d7ac 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -10,6 +10,16 @@ import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; +/** + * Incognito partitions deliberately omit the `persist:` prefix, which is what + * makes Chromium keep them in memory and discard them with the process. They + * still carry the product prefix so `isPartition` can admit them — the + * `will-attach-webview` gate rejects anything it does not recognise. + */ +const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; +const PROFILE_PARTITION_MARKER = "profile-"; + +export type BrowserSessionPartitionNamespace = "profile"; // Permissions granted to preview web content. `clipboard-sanitized-write` is the // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT @@ -99,20 +109,68 @@ export class BrowserSession extends Context.Service< { readonly getPartition: ( scope?: string, + persistent?: boolean, + namespace?: BrowserSessionPartitionNamespace, ) => Effect.Effect; readonly isPartition: (partition: string) => boolean; - readonly getSession: (scope?: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; + readonly getSession: ( + scope?: string, + persistent?: boolean, + namespace?: BrowserSessionPartitionNamespace, + ) => Effect.Effect; + /** Omit `partitions` to clear every known partition. */ + readonly clearCookies: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; + readonly clearCache: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserSession") {} +/** + * Restricts a clear to the given partitions. Omitting them keeps the historical + * "every partition" behaviour, which callers now only use for an explicit + * "all profiles" action — a per-profile clear must never reach across profiles. + */ +const selectSessions = ( + sessions: ReadonlyMap, + partitions: ReadonlyArray | undefined, +): ReadonlyArray => + [...sessions.entries()].filter( + ([partition]) => partitions === undefined || partitions.includes(partition), + ); + +/** + * Scope bytes for the partition digest. + * + * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` + * and `"p\ufffd"` would hash to the same partition and share cookies. Those + * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` + * first — and a literal backslash is doubled so the escape cannot be forged. + * Every well-formed scope passes through byte-for-byte unchanged, which keeps + * existing partitions (and the logins in them) where they are. + */ +const encodeScopeForDigest = (scope: string): Uint8Array => + new TextEncoder().encode( + scope + .replace(/\\/g, "\\\\") + .replace( + /[\ud800-\udbff](?![\udc00-\udfff])|(? `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`, + ), + ); + export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const sessionsRef = yield* SynchronizedRef.make>(new Map()); - const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { - const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( + const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( + scope = "shared", + persistent = true, + namespace?: BrowserSessionPartitionNamespace, + ) { + const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe( Effect.mapError( (cause) => new BrowserSessionPartitionDerivationError({ @@ -121,11 +179,19 @@ export const make = Effect.gen(function* BrowserSessionMake() { }), ), ); - return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; + const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX; + // Legacy/default partitions are prefix + hex digest. The non-hex profile + // marker creates a disjoint namespace while leaving every legacy default + // partition byte-for-byte unchanged. + return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Encoding.encodeHex(digest).slice(0, 20)}`; }); - const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { - const partition = yield* getPartition(scope); + const getSession = Effect.fn("BrowserSession.getSession")(function* ( + scope = "shared", + persistent = true, + namespace?: BrowserSessionPartitionNamespace, + ) { + const partition = yield* getPartition(scope, persistent, namespace); return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { const existing = sessions.get(partition); if (existing) return Effect.succeed([existing, sessions] as const); @@ -159,12 +225,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { return BrowserSession.of({ getPartition, - isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), + isPartition: (partition) => + partition.startsWith(PREVIEW_PARTITION_PREFIX) || + partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX), getSession, - clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { + clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.entries()].map(([partition, browserSession]) => + selectSessions(sessions, partitions).map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ @@ -180,10 +248,10 @@ export const make = Effect.gen(function* BrowserSessionMake() { { concurrency: "unbounded", discard: true }, ); }), - clearCache: Effect.fn("BrowserSession.clearCache")(function* () { + clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.entries()].map(([partition, browserSession]) => + selectSessions(sessions, partitions).map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), catch: (cause) => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8ee312110d86..01398721dd58 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -4409,7 +4409,11 @@ export class PreviewManager extends Context.Service< PreviewManager, { readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; - readonly getBrowserSession: (scope?: string) => Effect.Effect; + readonly getBrowserSession: ( + scope?: string, + persistent?: boolean, + namespace?: BrowserSession.BrowserSessionPartitionNamespace, + ) => Effect.Effect; readonly isBrowserPartition: (partition: string) => boolean; readonly createTab: ( tabId: string, @@ -4440,9 +4444,17 @@ export class PreviewManager extends Context.Service< audioMuted: boolean, ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; - readonly getBrowserPartition: (scope?: string) => Effect.Effect; + readonly clearCookies: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; + readonly clearCache: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; + readonly getBrowserPartition: ( + scope?: string, + persistent?: boolean, + namespace?: BrowserSession.BrowserSessionPartitionNamespace, + ) => Effect.Effect; readonly setAnnotationTheme: ( theme: DesktopPreviewAnnotationTheme, ) => Effect.Effect; @@ -4514,15 +4526,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { return PreviewManager.of({ setMainWindow: operations.setMainWindow, - getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { - return yield* browserSession - .getSession(scope) - .pipe( - Effect.mapError( - (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), - ), - ); - }), + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")( + function* (scope, persistent, namespace) { + return yield* browserSession + .getSession(scope, persistent, namespace) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), + ), + ); + }, + ), isBrowserPartition: browserSession.isPartition, createTab: operations.createTab, closeTab: operations.closeTab, @@ -4539,31 +4553,33 @@ export const make = Effect.gen(function* PreviewManagerMake() { setColorScheme: operations.setColorScheme, setAudioMuted: operations.setAudioMuted, openDevTools: operations.openDevTools, - clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { + clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { yield* browserSession - .clearCookies() + .clearCookies(partitions) .pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), ), ); }), - clearCache: Effect.fn("PreviewManager.clearCache")(function* () { + clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) { yield* browserSession - .clearCache() + .clearCache(partitions) .pipe( Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), ); }), - getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { - return yield* browserSession - .getPartition(scope) - .pipe( - Effect.mapError( - (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), - ), - ); - }), + getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")( + function* (scope, persistent, namespace) { + return yield* browserSession + .getPartition(scope, persistent, namespace) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), + ), + ); + }, + ), setAnnotationTheme: operations.setAnnotationTheme, pickElement: operations.pickElement, cancelPickElement: operations.cancelPickElement, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 95c5cc022c6d..4766b7a3439c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,8 @@ const clientSettings: ClientSettings = { browserDefaultAppearance: "dark", browserRecordingFrameRate: 60, browserAutoShowFloatingPreview: false, + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 8b3dabfa3386..d1fc142502db 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -58,6 +58,37 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("keeps the tab's profile across navigation and status reports", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + + const opened = yield* manager.open({ threadId, profileId: "work" }); + expect(opened.profileId).toBe("work"); + + // `navigate` and `reportStatus` rebuild the snapshot field by field + // rather than spreading it, so a new field is dropped unless carried + // explicitly — which would silently move the tab to another profile's + // partition on its first navigation. + const navigated = yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "localhost:5173", + }); + expect(navigated.profileId).toBe("work"); + + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" }, + canGoBack: true, + canGoForward: false, + }); + const listed = yield* manager.list({ threadId }); + expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work"); + }), + ); + it.effect("opens an Idle tab when no URL is supplied", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 09bbe0a41c76..a5b1f4da8db0 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -123,6 +123,7 @@ const buildLoadingSnapshot = (input: { readonly url: string; readonly title: string; readonly viewport: PreviewViewportSetting; + readonly profileId?: string | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -131,6 +132,7 @@ const buildLoadingSnapshot = (input: { canGoBack: false, canGoForward: false, viewport: input.viewport, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), updatedAt: input.updatedAt, }); @@ -138,6 +140,7 @@ const buildIdleSnapshot = (input: { readonly threadId: string; readonly tabId: string; readonly viewport: PreviewViewportSetting; + readonly profileId?: string | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -146,6 +149,7 @@ const buildIdleSnapshot = (input: { canGoBack: false, canGoForward: false, viewport: input.viewport, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), updatedAt: input.updatedAt, }); @@ -229,9 +233,16 @@ export const make = Effect.gen(function* PreviewManagerMake() { url: yield* normalizeUrl(input.url), title: "", viewport, + profileId: input.profileId, updatedAt, }) - : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt }); + : buildIdleSnapshot({ + threadId: input.threadId, + tabId, + viewport, + profileId: input.profileId, + updatedAt, + }); yield* SynchronizedRef.modifyEffect(stateRef, (state) => Effect.gen(function* () { const revision = state.revision + 1; @@ -275,6 +286,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { canGoBack: session.snapshot.canGoBack, canGoForward: session.snapshot.canGoForward, viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, + ...(session.snapshot.profileId === undefined + ? {} + : { profileId: session.snapshot.profileId }), updatedAt, }; return { @@ -308,6 +322,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { canGoBack: input.canGoBack, canGoForward: input.canGoForward, viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, + ...(session.snapshot.profileId === undefined + ? {} + : { profileId: session.snapshot.profileId }), updatedAt, }; const emit: PreviewEventDraft = diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 5425bca0b4bc..de7e23603298 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -93,6 +93,7 @@ export function ElectronBrowserHost() { initialUrl={url} viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT} pictureInPicture={pictureInPicture} + profileId={snapshot.profileId} zoomFactor={zoomFactor} /> ); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 77c65264aa94..564a2453b2be 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -49,11 +49,24 @@ export function HostedBrowserWebview(props: { readonly initialUrl: string | null; readonly viewport: PreviewViewportSetting; readonly pictureInPicture: boolean; + /** + * Fixed for the tab's lifetime: Electron only honours `partition` before the + * guest attaches, so a live change here would not move the tab anyway. + */ + readonly profileId: string | undefined; readonly zoomFactor: number; }) { - const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } = - props; - const config = usePreviewWebviewConfig(threadRef.environmentId); + const { + threadRef, + tabId, + runtimeTabId, + initialUrl, + viewport, + pictureInPicture, + zoomFactor, + profileId, + } = props; + const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts new file mode 100644 index 000000000000..bac9600c182b --- /dev/null +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; + +const settings = vi.hoisted(() => ({ current: {} as Record })); + +vi.mock("~/hooks/useSettings", () => ({ + getClientSettings: () => settings.current, + useClientSettings: () => undefined, + ensureClientSettingsHydrated: () => Promise.resolve(), +})); + +const { getBrowserDefaults } = await import("./browserDefaults"); + +const withDefaultProfile = (browserDefaultProfileId: string) => { + settings.current = { + browserDefaultViewport: { _tag: "fill" }, + browserDefaultZoomFactor: 1, + browserDefaultAppearance: "system", + browserAutoShowFloatingPreview: true, + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId, + }; + return getBrowserDefaults(); +}; + +describe("getBrowserDefaults profile resolution", () => { + it("keeps a configured persistent profile", () => { + expect(withDefaultProfile("work").profileId).toBe("work"); + }); + + it("falls back for an unknown profile", () => { + expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID); + }); + + it("refuses incognito as the default", () => { + // A stored incognito default would open every new tab into storage that is + // discarded on close, and the settings list no longer offers it — so the + // row badged "Default" must be the one tabs actually open under. + expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe( + DEFAULT_BROWSER_PROFILE_ID, + ); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index da8bf6a65826..eaae409568a2 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -13,10 +13,13 @@ * * @module browserDefaults */ -import type { - DesktopPreviewTabDefaults, - PreviewAppearancePreference, - PreviewViewportSetting, +import { + DEFAULT_BROWSER_PROFILE_ID, + resolveBrowserProfiles, + type BrowserProfile, + type DesktopPreviewTabDefaults, + type PreviewAppearancePreference, + type PreviewViewportSetting, } from "@t3tools/contracts"; import { @@ -32,6 +35,8 @@ export interface BrowserDefaults { readonly zoomFactor: number; readonly appearance: PreviewAppearancePreference; readonly autoShowFloatingPreview: boolean; + readonly profiles: ReadonlyArray; + readonly profileId: string; } const toBrowserDefaults = (settings: { @@ -39,12 +44,29 @@ const toBrowserDefaults = (settings: { readonly browserDefaultZoomFactor: number; readonly browserDefaultAppearance: PreviewAppearancePreference; readonly browserAutoShowFloatingPreview: boolean; -}): BrowserDefaults => ({ - viewport: settings.browserDefaultViewport, - zoomFactor: settings.browserDefaultZoomFactor, - appearance: settings.browserDefaultAppearance, - autoShowFloatingPreview: settings.browserAutoShowFloatingPreview, -}); + readonly browserProfiles: ReadonlyArray; + readonly browserDefaultProfileId: string; +}): BrowserDefaults => { + const profiles = resolveBrowserProfiles(settings.browserProfiles); + return { + viewport: settings.browserDefaultViewport, + zoomFactor: settings.browserDefaultZoomFactor, + appearance: settings.browserDefaultAppearance, + autoShowFloatingPreview: settings.browserAutoShowFloatingPreview, + profiles, + // A default pointing at a deleted profile falls back rather than opening + // tabs into a partition with no profile behind it. + // Incognito is a per-tab choice, not a default: a profile that discards + // everything on close would leave every new tab signed out. Excluding it + // here keeps the resolved default equal to what the settings list offers, + // so the row badged "Default" is the one tabs actually open under. + profileId: + profiles.find( + (profile) => + profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito", + )?.id ?? DEFAULT_BROWSER_PROFILE_ID, + }; +}; /** Non-hook accessor for imperative open paths (menu actions, automation hosts). */ export function getBrowserDefaults(): BrowserDefaults { @@ -89,6 +111,13 @@ export function browserDefaultOpenViewport( return defaults.viewport; } +/** Profile a tab opens under when the caller doesn't name one. */ +export function browserDefaultOpenProfileId( + defaults: BrowserDefaults = getBrowserDefaults(), +): string { + return defaults.profileId; +} + /** * The viewport to switch to when the user turns the device toolbar on for a tab * currently in fill mode. diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 4e540a9a0963..f506e42e73e5 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -23,6 +23,12 @@ import { } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "./browserDefaults"; + export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); @@ -42,9 +48,18 @@ export async function openUrlInPreview(input: { readonly url: string; readonly openPreview: OpenPreviewMutation; }): Promise> { + const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Built here rather than via `openPreviewSession` because this path + // maps the result differently, so the configured defaults have to be + // applied explicitly or file/link opens would ignore them. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + }, }); return mapAtomCommandResult(result, (snapshot) => { applyPreviewServerSnapshot(input.threadRef, snapshot); diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts index 35eb665eb7e3..9ce113dce981 100644 --- a/apps/web/src/browser/previewWebviewConfigState.test.ts +++ b/apps/web/src/browser/previewWebviewConfigState.test.ts @@ -13,7 +13,9 @@ const environmentId = EnvironmentId.make("environment-1"); describe("loadPreviewWebviewConfig", () => { it.effect("reports a structurally distinct missing-bridge failure", () => Effect.gen(function* () { - const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip); + const error = yield* loadPreviewWebviewConfig(environmentId, undefined, null).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); expect(error.environmentId).toBe(environmentId); @@ -25,7 +27,7 @@ describe("loadPreviewWebviewConfig", () => { it.effect("preserves the bridge rejection as the load failure cause", () => Effect.gen(function* () { const cause = new Error("ipc unavailable"); - const error = yield* loadPreviewWebviewConfig(environmentId, { + const error = yield* loadPreviewWebviewConfig(environmentId, undefined, { getPreviewConfig: () => Promise.reject(cause), }).pipe(Effect.flip); @@ -36,22 +38,23 @@ describe("loadPreviewWebviewConfig", () => { }), ); - it.effect("forwards the environment id to the bridge", () => + it.effect("forwards the environment id and profile to the bridge", () => Effect.gen(function* () { - let requestedEnvironmentId: EnvironmentId | null = null; + let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null; const config = { partition: "persist:test-preview", webPreferences: "sandbox=yes", preloadUrl: null, }; - const result = yield* loadPreviewWebviewConfig(environmentId, { - getPreviewConfig: (input) => { - requestedEnvironmentId = input; + const result = yield* loadPreviewWebviewConfig(environmentId, "work", { + getPreviewConfig: (requestedEnvironmentId, profileId) => { + requested = { environmentId: requestedEnvironmentId, profileId }; return Promise.resolve(config); }, }); - expect(requestedEnvironmentId).toBe(environmentId); + // The partition is derived in main from both, so both have to arrive. + expect(requested).toEqual({ environmentId, profileId: "work" }); expect(result).toEqual(config); }), ); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 6f1cf058e38c..6decff578248 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -45,6 +45,7 @@ type PreviewConfigBridge = Pick; export const loadPreviewWebviewConfig = ( environmentId: EnvironmentId, + profileId?: string, bridge: PreviewConfigBridge | null = previewBridge, ): Effect.Effect => { if (bridge === null) { @@ -52,25 +53,52 @@ export const loadPreviewWebviewConfig = ( } return Effect.tryPromise({ - try: () => bridge.getPreviewConfig(environmentId), + try: () => bridge.getPreviewConfig(environmentId, profileId), catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }), }); }; -const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make(loadPreviewWebviewConfig(environmentId)).pipe( +/** + * `Atom.family` keys on its argument, so the environment and profile are + * folded into one string: passing an object would allocate a fresh entry on + * every render. + * + * The profile is the tail rather than a second field, so an id containing the + * delimiter round-trips whole instead of being truncated into a different + * profile's key. `BrowserProfileId` rejects control characters, which is what + * makes the environment side of the split unambiguous. + */ +const CONFIG_KEY_DELIMITER = "\u0000"; + +const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string => + `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`; + +const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => { + const delimiter = key.indexOf(CONFIG_KEY_DELIMITER); + const environmentId = (delimiter === -1 ? key : key.slice(0, delimiter)) as EnvironmentId; + const profileId = delimiter === -1 ? "" : key.slice(delimiter + CONFIG_KEY_DELIMITER.length); + return { + environmentId, + ...(profileId === "" ? {} : { profileId }), + }; +}; + +const previewWebviewConfigAtom = Atom.family((key: string) => { + const { environmentId, profileId } = parseConfigKey(key); + return Atom.make(loadPreviewWebviewConfig(environmentId, profileId)).pipe( Atom.swr({ staleTime: PREVIEW_CONFIG_STALE_TIME_MS, revalidateOnMount: true, }), Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), - Atom.withLabel(`preview:webview-config:${environmentId}`), - ), -); + Atom.withLabel(`preview:webview-config:${key}`), + ); +}); export function usePreviewWebviewConfig( environmentId: EnvironmentId, + profileId?: string, ): DesktopPreviewWebviewConfig | null { - const result = useAtomValue(previewWebviewConfigAtom(environmentId)); + const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId))); return Option.getOrNull(AsyncResult.value(result)); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 305773955d0e..bee12b522944 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3608,10 +3608,17 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const createBrowserSurface = useCallback(() => { - if (!activeThreadRef) return; - void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); - }, [activeThreadRef, openPreview]); + const createBrowserSurface = useCallback( + (profileId?: string) => { + if (!activeThreadRef) return; + void addBrowserSurface({ + threadRef: activeThreadRef, + openPreview, + ...(profileId === undefined ? {} : { profileId }), + }); + }, + [activeThreadRef, openPreview], + ); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); @@ -7664,7 +7671,8 @@ function ChatViewContent(props: ChatViewProps) { onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={createBrowserSurface} + onAddBrowser={() => createBrowserSurface()} + onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} @@ -7704,7 +7712,8 @@ function ChatViewContent(props: ChatViewProps) { onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={createBrowserSurface} + onAddBrowser={() => createBrowserSurface()} + onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 7b0ae9b4c201..ebfda100b533 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -4,11 +4,20 @@ import { describe, expect, it } from "vite-plus/test"; import { RightPanelTabs, + shouldOpenDefaultBrowserProfileFromMenuClick, surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, tabMuteMenuItem, } from "./RightPanelTabs"; +describe("browser profile submenu", () => { + it("reserves touch clicks for opening the choices while mouse clicks use the default", () => { + expect(shouldOpenDefaultBrowserProfileFromMenuClick("touch")).toBe(false); + expect(shouldOpenDefaultBrowserProfileFromMenuClick("mouse")).toBe(true); + expect(shouldOpenDefaultBrowserProfileFromMenuClick(undefined)).toBe(true); + }); +}); + function shortcutEvent( key: string, overrides: Partial[1]> = {}, @@ -104,6 +113,7 @@ function renderTabs( onCloseAllSurfaces={() => undefined} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} + onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} onAddDiff={() => undefined} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 40db79e80f56..c48dceb048a6 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -30,7 +30,17 @@ import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuItem, + MenuPopup, + MenuShortcut, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "~/components/ui/menu"; +import { useBrowserDefaults } from "~/browser/browserDefaults"; import { ScrollArea } from "~/components/ui/scroll-area"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; @@ -69,6 +79,12 @@ interface RightPanelTabsProps { onCloseAllSurfaces: () => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; + /** + * Separate from `onAddBrowser` on purpose: that one is passed directly as a + * DOM click handler, and a `(profileId?: string)` signature would silently + * accept the MouseEvent as a profile id. + */ + onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -94,6 +110,12 @@ export interface PullRequestTabStatus { isDraft: boolean; } +export function shouldOpenDefaultBrowserProfileFromMenuClick( + pointerType: string | undefined, +): boolean { + return pointerType !== "touch"; +} + const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", terminal: "Terminal surfaces are only available from a project thread.", @@ -600,6 +622,7 @@ function SurfaceIcon({ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); @@ -911,6 +934,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) { > {addSurfaceActions.map((action) => { const Icon = action.icon; + // Browser collapses into one row: clicking the trigger opens + // the default profile (the common case stays one click), + // while hover or arrow reveals the profiles. The choice + // lives at open time because a tab's profile is fixed then — + // Electron only honours a partition before attach. + if (action.label === "Browser" && action.available) { + return ( + + { + const pointerType = + "pointerType" in event.nativeEvent && + typeof event.nativeEvent.pointerType === "string" + ? event.nativeEvent.pointerType + : undefined; + // Touch has no hover path to the profile choices: + // its first tap opens the submenu, then a profile + // is selected there. Mouse click keeps the common + // default-profile action at one click. + if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) + return; + setAddSurfaceMenuOpen(false); + action.onClick(); + }} + > + + {action.label} + {action.shortcut} + + {/* + Capped and truncated: profile names are user-supplied + and run to 48 characters, which would otherwise widen + the popup to fit-content and wrap. + */} + + {browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)} + > + {profile.name} + + ))} + + + ); + } return ( {}; @@ -85,6 +90,7 @@ export function PreviewChromeRow({ pickDisabled, pickDisabledReason, trailingActions, + leadingActions, }: Props) { const inputRef = useRef(null); const [draft, setDraft] = useState(url); @@ -166,6 +172,8 @@ export function PreviewChromeRow({ + {leadingActions} + void; + /** Environment the tab belongs to; scopes storage clearing to its partitions. */ + environmentId: EnvironmentId; + /** Profile the tab was opened under, if the server recorded one. */ + /** + * Required: the IPC layer reads an absent profile as "every profile", so a + * tab whose own profile is unknown must resolve the default before it gets + * here rather than passing the gap along. + */ + profileId: string; + /** Profile display name, shown so the menu says which data is being cleared. */ + profileName: string | undefined; } /** @@ -66,6 +79,9 @@ export function PreviewMoreMenu({ onToggleDeviceToolbar, nativePictureInPicture, onNativePictureInPicture, + environmentId, + profileId, + profileName, }: Props) { if (!previewBridge) return null; const bridge = previewBridge; @@ -177,12 +193,37 @@ export function PreviewMoreMenu({ - void bridge.clearCookies().catch(() => undefined)}> - Clear cookies - - void bridge.clearCache().catch(() => undefined)}> - Clear cache - + {/* + Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel` + reads its context and throws without one. The heading also answers + which profile the tab is in, which is otherwise invisible: it is fixed + at open and nothing else in the chrome shows it. + */} + + {/* + The heading carries the profile so the actions below can keep + fixed-length labels: repeating a name of up to 48 characters in + each one drove the popup far past its width. + */} + {profileName ? ( + // Truncation sits on the label itself: it renders a block box, so + // `text-overflow` on an inline child inside it never applies and a + // long name would push the popup past its width instead. + Profile: {profileName} + ) : null} + + void bridge.clearCookies(environmentId, profileId).catch(() => undefined) + } + > + Clear cookies + + void bridge.clearCache(environmentId, profileId).catch(() => undefined)} + > + Clear cache + + ); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index fd6ac25ceced..808842044e92 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -1,4 +1,6 @@ import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, EnvironmentId, @@ -37,6 +39,15 @@ const mocks = vi.hoisted(() => ({ const EMPTY_HISTORY: never[] = []; +const STUB_BROWSER_DEFAULTS = { + viewport: FILL_PREVIEW_VIEWPORT, + zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, + appearance: DEFAULT_PREVIEW_APPEARANCE, + autoShowFloatingPreview: true, + profiles: BUILT_IN_BROWSER_PROFILES, + profileId: DEFAULT_BROWSER_PROFILE_ID, +}; + vi.mock("~/browserHistoryStore", () => ({ recordVisitForThread: mocks.recordVisitForThread, setTitleForThreadUrl: vi.fn(), @@ -53,19 +64,10 @@ vi.mock("~/state/session", () => ({ // `useSettings` -> `state/server`, which would drag the whole settings and // connection graph into a test that only cares about the browser chrome. vi.mock("~/browser/browserDefaults", () => ({ - useBrowserDefaults: () => ({ - viewport: FILL_PREVIEW_VIEWPORT, - zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, - appearance: DEFAULT_PREVIEW_APPEARANCE, - autoShowFloatingPreview: true, - }), - getBrowserDefaults: () => ({ - viewport: FILL_PREVIEW_VIEWPORT, - zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, - appearance: DEFAULT_PREVIEW_APPEARANCE, - autoShowFloatingPreview: true, - }), + useBrowserDefaults: () => STUB_BROWSER_DEFAULTS, + getBrowserDefaults: () => STUB_BROWSER_DEFAULTS, browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT, + browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID, browserDefaultTabState: () => ({ zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -249,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); -import { PreviewView } from "./PreviewView"; +import { PreviewView, previewProfileName } from "./PreviewView"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; const TEST_THREAD_REF = { @@ -347,6 +349,12 @@ describe("PreviewView navigation", () => { mocks.recordVisitForThread.mockClear(); }); + it("labels a tab whose saved profile was removed", () => { + expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe( + "Removed profile", + ); + }); + it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 063314863fec..6d431a48e3db 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -3,6 +3,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { + DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, type PreviewAnnotationPayload, type PreviewViewportSetting, @@ -48,6 +49,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; +import { Badge } from "~/components/ui/badge"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { usePreviewSession } from "./usePreviewSession"; @@ -61,6 +63,7 @@ import { useActiveBrowserRecordingTabIds, } from "~/browser/browserRecording"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; interface Props { threadRef: ScopedThreadRef; @@ -73,6 +76,13 @@ interface Props { ) => void; } +export function previewProfileName( + profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>, + profileId: string, +): string { + return profiles.find((profile) => profile.id === profileId)?.name ?? "Removed profile"; +} + const localApi = typeof window === "undefined" ? null : ensureLocalApi(); /** @@ -144,6 +154,14 @@ export function PreviewView({ const controller = desktopOverlay?.controller ?? "none"; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const browserDefaults = useBrowserDefaults(); + // A tab created before profiles existed carries no profile of its own. It + // runs in the built-in `default` partition — the scope the browser used + // before profiles — not in whatever profile is configured as the default + // now, so that is what its label names and its clear actions target. + // Passing the snapshot's raw `undefined` through would reach the IPC layer + // as "every profile". + const activeProfileId = snapshot?.profileId ?? DEFAULT_BROWSER_PROFILE_ID; + const activeProfileName = previewProfileName(browserDefaults.profiles, activeProfileId); const panelRect = useBrowserSurfaceStore((state) => runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); @@ -686,9 +704,32 @@ export function PreviewView({ pickDisabledReason={ isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined } + leadingActions={ + // Only when it differs from the default: labelling every tab + // "Default" would be noise on the common case, while a tab in + // another profile is exactly what needs calling out. + activeProfileId !== browserDefaults.profileId ? ( + // Capped: profile names run to 48 characters, and an unbounded + // badge in this row takes its width from the URL input, the only + // flexible element in the compact chrome. The cap sits on the + // badge and the truncation on an inner span, because `Badge` is an + // `inline-flex` with `whitespace-nowrap` — `text-overflow` never + // reaches a bare text node inside it, so the name would be cut off + // at both ends with no ellipsis. + + }> + {activeProfileName} + + {activeProfileName} + + ) : null + } trailingActions={ previewBridge ? ( { }); describe("addBrowserSurface", () => { + it("opens under the requested profile", async () => { + const openPreview = vi.fn(async (_input: PreviewOpenInput) => + AsyncResult.success(snapshot("tab-1")), + ); + + await addBrowserSurface({ + threadRef, + openPreview: ({ input }) => openPreview(input), + profileId: "profile-work", + }); + + expect(openPreview).toHaveBeenCalledWith({ + threadId: "thread-1", + viewport: FILL_PREVIEW_VIEWPORT, + profileId: "profile-work", + }); + }); + it("creates another preview session when a browser tab is already active", async () => { const first = snapshot("tab-1"); const second = snapshot("tab-2"); @@ -48,6 +67,7 @@ describe("addBrowserSurface", () => { expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, + profileId: DEFAULT_BROWSER_PROFILE_ID, }); expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); expect( diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 4eecac695cea..622cdbec2f1c 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -13,10 +13,13 @@ import { openPreviewSession } from "./openPreviewSession"; export async function addBrowserSurface(input: { readonly threadRef: ScopedThreadRef; readonly openPreview: OpenPreviewMutation; + /** Omit to use the configured default profile. */ + readonly profileId?: string | undefined; }): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), }); return mapAtomCommandResult(result, (snapshot) => { useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 138c3dd368cb..ef3d51a9e7fa 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -49,6 +50,7 @@ describe("openPreviewSession", () => { expect(open).toHaveBeenCalledWith({ threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, + profileId: DEFAULT_BROWSER_PROFILE_ID, }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); @@ -67,6 +69,7 @@ describe("openPreviewSession", () => { threadId: "thread-1", url: "t3.chat", viewport: FILL_PREVIEW_VIEWPORT, + profileId: DEFAULT_BROWSER_PROFILE_ID, }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 1a3ceabad3ad..deb5465ebc28 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -7,7 +7,11 @@ import type { } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -19,17 +23,23 @@ interface OpenPreviewSessionInput { url?: string; /** Overrides the configured default; automation passes an explicit size. */ viewport?: PreviewViewportSetting; + /** Overrides the configured default profile. */ + profileId?: string; } export async function openPreviewSession( input: OpenPreviewSessionInput, ): Promise> { + // Resolved once: a tab opened before client settings hydrate would otherwise + // be born at the schema defaults and never corrected. + const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { threadId: input.threadRef.threadId, ...(input.url === undefined ? {} : { url: input.url }), - viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: input.viewport ?? browserDefaultOpenViewport(defaults), + profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 47f03761f6bb..9a5656d76a1c 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -1,7 +1,7 @@ import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { openTerminalLinkInPreview, @@ -20,6 +20,21 @@ vi.mock("~/rightPanelStore", () => ({ }, })); +const browserDefaultsMocks = vi.hoisted(() => ({ + resolve: vi.fn(), +})); + +vi.mock("~/browser/browserDefaults", () => ({ + resolveBrowserDefaults: browserDefaultsMocks.resolve, + browserDefaultOpenViewport: (defaults: { viewport: unknown }) => defaults.viewport, + browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId, +})); + +const hydratedDefaults = { + viewport: { _tag: "fixed", width: 1280, height: 720 } as const, + profileId: "work", +}; + const threadRef = { environmentId: "local" as ScopedThreadRef["environmentId"], threadId: "thread-1" as ScopedThreadRef["threadId"], @@ -34,11 +49,54 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-20T00:00:00.000Z", }; +beforeEach(() => { + browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults); +}); + afterEach(() => { vi.restoreAllMocks(); }); describe("openTerminalLinkInPreview", () => { + it("waits for hydrated viewport and profile defaults before opening", async () => { + let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined; + browserDefaultsMocks.resolve.mockImplementationOnce( + () => + new Promise((resolve) => { + hydrate = resolve; + }), + ); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + const opening = openTerminalLinkInPreview({ + url: "http://localhost:3000/", + position: { x: 12, y: 34 }, + threadRef, + openPreview, + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser: vi.fn(), + }); + + await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); + expect(openPreview).not.toHaveBeenCalled(); + hydrate?.(hydratedDefaults); + await opening; + + expect(openPreview).toHaveBeenCalledWith({ + environmentId: "local", + input: { + threadId: "thread-1", + url: "http://localhost:3000/", + viewport: hydratedDefaults.viewport, + profileId: hydratedDefaults.profileId, + }, + }); + }); + it("preserves context-menu failures with terminal link context before falling back", async () => { const cause = new Error("menu unavailable"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index f4e0373a73c3..f5725fc2acfa 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -3,6 +3,11 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime" import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -82,9 +87,17 @@ export async function openTerminalLinkInPreview( } if (choice === "open-in-preview") { + const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Same reason as `openUrlInPreview`: this path handles its own result + // mapping, so the configured defaults are applied explicitly. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts new file mode 100644 index 000000000000..26eef9536b6f --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings"; + +const environmentId = "environment-a" as EnvironmentId; +const secondEnvironmentId = "environment-b" as EnvironmentId; + +describe("clearBrowserProfileData", () => { + it("waits for cookie and cache cleanup", async () => { + const clearCookies = vi.fn().mockResolvedValue(undefined); + const clearCache = vi.fn().mockResolvedValue(undefined); + + await clearBrowserProfileData({ clearCookies, clearCache }, [environmentId], "profile-a"); + + expect(clearCookies).toHaveBeenCalledWith(environmentId, "profile-a"); + expect(clearCache).toHaveBeenCalledWith(environmentId, "profile-a"); + }); + + it("clears every known environment before succeeding", async () => { + const clearCookies = vi.fn().mockResolvedValue(undefined); + const clearCache = vi.fn().mockResolvedValue(undefined); + + await clearBrowserProfileData( + { clearCookies, clearCache }, + [environmentId, secondEnvironmentId], + "profile-a", + ); + + expect(clearCookies.mock.calls).toEqual([ + [environmentId, "profile-a"], + [secondEnvironmentId, "profile-a"], + ]); + expect(clearCache.mock.calls).toEqual([ + [environmentId, "profile-a"], + [secondEnvironmentId, "profile-a"], + ]); + }); + + it("propagates cleanup failures", async () => { + const failure = new Error("clear failed"); + await expect( + clearBrowserProfileData( + { + clearCookies: vi.fn().mockRejectedValue(failure), + clearCache: vi.fn().mockResolvedValue(undefined), + }, + [environmentId], + "profile-a", + ), + ).rejects.toBe(failure); + }); + + it("does not report success without an environment or bridge", async () => { + const bridge = { + clearCookies: vi.fn().mockResolvedValue(undefined), + clearCache: vi.fn().mockResolvedValue(undefined), + }; + + await expect(clearBrowserProfileData(bridge, [], "profile-a")).rejects.toThrow(); + await expect(clearBrowserProfileData(null, [environmentId], "profile-a")).rejects.toThrow(); + expect(bridge.clearCookies).not.toHaveBeenCalled(); + expect(bridge.clearCache).not.toHaveBeenCalled(); + }); +}); + +describe("browserProfileRemovalAvailable", () => { + it("requires a ready non-empty catalog and desktop bridge", () => { + expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true); + expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false); + expect(browserProfileRemovalAvailable(true, false, 1)).toBe(false); + expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 2757866ecbbd..af08091a520a 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,8 +7,13 @@ * @module IntegrationsSettings */ import { + BROWSER_PROFILE_MAX_COUNT, + type BrowserProfile, + type EnvironmentId, + BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, + DEFAULT_BROWSER_PROFILE_ID, DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, @@ -19,17 +24,35 @@ import { PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_ZOOM_LEVELS, + findBrowserProfile, + isBuiltInBrowserProfileId, + resolveBrowserProfiles, type PreviewAppearancePreference, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; +import { useState } from "react"; import type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; +import { previewBridge } from "~/components/preview/previewBridge"; +import { cn, randomUUID } from "~/lib/utils"; +import { useEnvironments } from "~/state/environments"; import { isElectron } from "../../env"; +import { Badge } from "../ui/badge"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { DraftInput } from "../ui/draft-input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; import { Select, @@ -43,7 +66,9 @@ import { import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { + getClientSettings, useClientSettings, + useClientSettingsHydrated, usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -54,11 +79,41 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; +import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { searchableSetting } from "./settingsSearch"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; +type BrowserProfileDataBridge = Pick< + NonNullable, + "clearCookies" | "clearCache" +>; + +export async function clearBrowserProfileData( + bridge: BrowserProfileDataBridge | null, + environmentIds: ReadonlyArray, + profileId: string, +): Promise { + if (bridge === null || environmentIds.length === 0) { + throw new Error("Browser profile data is not available to clear."); + } + await Promise.all( + environmentIds.flatMap((environmentId) => [ + bridge.clearCookies(environmentId, profileId), + bridge.clearCache(environmentId, profileId), + ]), + ); +} + +export function browserProfileRemovalAvailable( + bridgeAvailable: boolean, + environmentsReady: boolean, + environmentCount: number, +): boolean { + return bridgeAvailable && environmentsReady && environmentCount > 0; +} + /** * The size a "Responsive" default falls back to when the user switches away * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry @@ -501,11 +556,306 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode ); } +/** + * Create, rename, and remove browser profiles. + * + * Built-ins render without controls: they are synthesized rather than stored, + * so there is nothing to rename and removing them would strand every tab that + * opened under them. + */ +function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { + const userProfiles = useClientSettings((settings) => settings.browserProfiles); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdatePrimarySettings(); + const { environments, isReady: environmentsReady } = useEnvironments(); + const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); + const [profileRemovalError, setProfileRemovalError] = useState(null); + const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); + const removalAvailable = browserProfileRemovalAvailable( + previewBridge !== null, + environmentsReady, + environments.length, + ); + const profileWritesDisabled = disabled || !settingsHydrated; + + const addProfile = () => { + if (!settingsHydrated) return; + const currentProfiles = getClientSettings().browserProfiles; + if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; + const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name)); + let name = "New profile"; + for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; + updateSettings({ + browserProfiles: [ + ...currentProfiles, + { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, + ], + }); + }; + + const renameProfile = (id: string, next: string) => { + if (!settingsHydrated) return; + const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); + if (name === "") return; + const currentProfiles = getClientSettings().browserProfiles; + updateSettings({ + browserProfiles: currentProfiles.map((profile) => + profile.id === id ? { ...profile, name } : profile, + ), + }); + }; + + const removeProfile = async (id: string) => { + if (!settingsHydrated) return; + if (!removalAvailable) { + setProfileRemovalError("Connect to an environment before removing this profile."); + return; + } + setProfileRemovalError(null); + setProfileRemovalInFlight(true); + // Drop the partition's data too, otherwise a removed profile's cookies + // stay on disk with nothing in the UI pointing at them. + try { + await clearBrowserProfileData( + previewBridge, + environmentsReady ? environments.map((environment) => environment.environmentId) : [], + id, + ); + } catch { + setProfileRemovalError("Profile data could not be deleted. Try again."); + setProfileRemovalInFlight(false); + return; + } + const currentSettings = getClientSettings(); + updateSettings({ + browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id), + // Reassign the default rather than leaving it pointing at nothing. + ...(currentSettings.browserDefaultProfileId === id + ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } + : {}), + }); + setProfileRemovalInFlight(false); + setProfilePendingRemoval(null); + }; + + return ( + = BROWSER_PROFILE_MAX_COUNT} + onClick={addProfile} + > + + Add profile + + } + > + {/* + Each profile is its own bounded row, and the list carries the bottom + spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows + stack on narrow viewports with a larger gap inside a row than between + rows, which reads as the remove button belonging to the profile below. + */} +
+ {resolveBrowserProfiles(userProfiles).map((profile) => { + const builtIn = isBuiltInBrowserProfileId(profile.id); + return ( +
+ {builtIn ? ( + // Dimmed here rather than on the list, which is the only + // content in the row without a disabled treatment of its own: + // a wrapper-level dim would stack with the rename field's and + // the remove button's, landing them near 0.41 while every + // other disabled control in the block sits at 0.64. + + {profile.name} + + {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} + + + ) : ( + renameProfile(profile.id, next)} + /> + )} + {builtIn ? null : ( + + + + + } + /> + + {removalAvailable + ? "Remove profile and its data" + : "Connect to an environment to remove this profile"} + + + )} +
+ ); + })} +
+ { + if (!open && !profileRemovalInFlight) { + setProfilePendingRemoval(null); + setProfileRemovalError(null); + } + }} + > + + + Remove “{profilePendingRemoval?.name}”? + + Its cookies, logins, and cache are deleted with it. Tabs already open in this profile + stay open until you close them. + + {profileRemovalError ? ( +

+ {profileRemovalError} +

+ ) : null} + {!removalAvailable ? ( +

+ Connect to an environment to remove this profile and its data. +

+ ) : null} +
+ + } + > + Cancel + + + +
+
+
+ ); +} + +function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { + const userProfiles = useClientSettings((settings) => settings.browserProfiles); + const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdatePrimarySettings(); + const profileWritesDisabled = disabled || !settingsHydrated; + // Incognito is deliberately absent: as a default it would open every tab + // into storage that is discarded on close. + const profiles = resolveBrowserProfiles(userProfiles).filter( + (profile) => profile.kind !== "incognito", + ); + const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; + + return ( + { + if (settingsHydrated) { + updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }); + } + }} + /> + ) : null + } + control={ + + } + /> + ); +} + export function IntegrationsSettingsPanel() { // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( <> + + diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 44859e5cb040..20aea7d3f77e 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -312,6 +312,18 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/integrations", searchTerms: ["allow open drive preview tools sessions"], }, + { + id: "browser-profiles", + title: "Browser profiles", + to: "/settings/integrations", + targetId: "browser", + }, + { + id: "browser-default-profile", + title: "Default browser profile", + to: "/settings/integrations", + targetId: "browser", + }, { id: "browser-default-viewport", title: "Default browser viewport", diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index b66782ebe2d1..d7892cb228ab 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -235,7 +235,12 @@ function MenuSubTrigger({ return ( svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0", className, )} data-inset={inset} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 4c0bf1ad310e..2864eb4ced95 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1867,6 +1867,7 @@ function PullRequestsRouteView() { onCloseAllSurfaces={closeAllSurfaces} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} + onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} diff --git a/packages/contracts/src/browserProfile.test.ts b/packages/contracts/src/browserProfile.test.ts new file mode 100644 index 000000000000..d53423bd6eef --- /dev/null +++ b/packages/contracts/src/browserProfile.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "@effect/vitest"; + +import * as Schema from "effect/Schema"; + +import { + BrowserProfileId, + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, + findBrowserProfile, + isBuiltInBrowserProfileId, + resolveBrowserProfiles, + type BrowserProfile, +} from "./browserProfile.ts"; + +const work: BrowserProfile = { id: "profile-work", name: "Work", kind: "persistent" }; + +describe("resolveBrowserProfiles", () => { + it("lists built-ins ahead of the user's own profiles", () => { + const resolved = resolveBrowserProfiles([work]); + + expect(resolved.map((profile) => profile.id)).toEqual([ + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, + work.id, + ]); + }); + + it("drops stored entries that collide with a built-in id", () => { + // Built-ins are synthesized rather than stored, so a hand-edited settings + // file must not be able to shadow Default with a persistent partition of + // its own — every tab already opened under Default would follow it. + const resolved = resolveBrowserProfiles([ + { id: DEFAULT_BROWSER_PROFILE_ID, name: "Hijacked", kind: "persistent" }, + { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Not incognito", kind: "persistent" }, + work, + ]); + + expect(resolved).toEqual([...BUILT_IN_BROWSER_PROFILES, work]); + }); + + it("keeps incognito ephemeral", () => { + const incognito = findBrowserProfile(resolveBrowserProfiles([]), INCOGNITO_BROWSER_PROFILE_ID); + + expect(incognito?.kind).toBe("incognito"); + }); +}); + +describe("findBrowserProfile", () => { + it("returns nothing for an id that no longer exists", () => { + // The settings UI relies on this to fall back rather than opening tabs + // into a partition with no profile behind it. + expect(findBrowserProfile(resolveBrowserProfiles([]), work.id)).toBeUndefined(); + expect(findBrowserProfile(resolveBrowserProfiles([work]), undefined)).toBeUndefined(); + }); +}); + +describe("isBuiltInBrowserProfileId", () => { + it("separates built-ins from user profiles", () => { + expect(isBuiltInBrowserProfileId(DEFAULT_BROWSER_PROFILE_ID)).toBe(true); + expect(isBuiltInBrowserProfileId(INCOGNITO_BROWSER_PROFILE_ID)).toBe(true); + expect(isBuiltInBrowserProfileId(work.id)).toBe(false); + }); +}); + +describe("resolveBrowserProfiles normalization", () => { + it("keeps only the first entry for a repeated id", () => { + // Both map to the same Electron partition, so presenting two would offer + // isolated identities that in fact share every cookie. + const resolved = resolveBrowserProfiles([ + { id: "work", name: "Work", kind: "persistent" }, + { id: "work", name: "Work (old)", kind: "persistent" }, + ]); + + expect(resolved.filter((profile) => profile.id === "work")).toEqual([ + { id: "work", name: "Work", kind: "persistent" }, + ]); + }); + + it("reports a custom incognito profile as persistent", () => { + // Partition persistence is keyed off the built-in incognito id alone, so + // a custom profile claiming that kind keeps its cookies across restarts. + // Labelling it ephemeral would be a promise the partition layer breaks. + const resolved = resolveBrowserProfiles([ + { id: "throwaway", name: "Throwaway", kind: "incognito" }, + ]); + + expect(resolved.find((profile) => profile.id === "throwaway")).toEqual({ + id: "throwaway", + name: "Throwaway", + kind: "persistent", + }); + }); + + it("still lets the built-in incognito profile stay ephemeral", () => { + const incognito = resolveBrowserProfiles([]).find( + (profile) => profile.id === INCOGNITO_BROWSER_PROFILE_ID, + ); + + expect(incognito?.kind).toBe("incognito"); + }); +}); + +describe("BrowserProfileId", () => { + it("rejects control characters", () => { + // Ids are folded into delimiter-joined cache keys on the client, so one + // carrying the delimiter would resolve to another profile's partition. + expect(Schema.is(BrowserProfileId)("profile-a\u0000b")).toBe(false); + expect(Schema.is(BrowserProfileId)("profile-a")).toBe(true); + }); +}); diff --git a/packages/contracts/src/browserProfile.ts b/packages/contracts/src/browserProfile.ts new file mode 100644 index 000000000000..39dd58dfb336 --- /dev/null +++ b/packages/contracts/src/browserProfile.ts @@ -0,0 +1,99 @@ +/** + * Browser profiles - named identities for the in-app preview browser. + * + * Each profile maps to its own Electron session partition, so cookies and + * storage are isolated between them: a tab opened under "Work" cannot see + * "Personal"'s logins. Profiles are client-local, like the other browser + * defaults, because the Chromium guest they configure is desktop-local. + * + * Two profiles are built in and cannot be edited or removed: + * - `default` keeps the partition scope the browser used before profiles + * existed, so upgrading does not sign anyone out. + * - `incognito` maps to a non-persistent partition for throwaway sessions. + * + * @module BrowserProfile + */ +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48; +export const BROWSER_PROFILE_MAX_COUNT = 24; + +/** + * Control characters are rejected because ids are folded into delimiter-joined + * cache keys on the client; one carrying the delimiter would resolve to a + * different profile's partition. + */ +export const BrowserProfileId = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[^\p{Cc}]+$/u), +); +export type BrowserProfileId = typeof BrowserProfileId.Type; + +export const BrowserProfileName = TrimmedNonEmptyString.check( + Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH), +); + +/** + * `persistent` profiles keep cookies on disk across restarts; `incognito` + * uses an in-memory partition that Chromium discards with the process. + */ +export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]); +export type BrowserProfileKind = typeof BrowserProfileKind.Type; + +export const BrowserProfile = Schema.Struct({ + id: BrowserProfileId, + name: BrowserProfileName, + kind: BrowserProfileKind, +}); +export type BrowserProfile = typeof BrowserProfile.Type; + +export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default"; +export const INCOGNITO_BROWSER_PROFILE_ID: BrowserProfileId = "incognito"; + +/** + * Built-ins are synthesized rather than stored, so they cannot be renamed out + * of existence or deleted by editing the settings file by hand. + */ +export const BUILT_IN_BROWSER_PROFILES: ReadonlyArray = [ + { id: DEFAULT_BROWSER_PROFILE_ID, name: "Default", kind: "persistent" }, + { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Incognito", kind: "incognito" }, +]; + +export function isBuiltInBrowserProfileId(id: string): boolean { + return BUILT_IN_BROWSER_PROFILES.some((profile) => profile.id === id); +} + +/** + * The full picker list: built-ins first, then the user's own profiles. + * + * Three things are normalized away, because each would present a profile the + * partition layer does not actually deliver: + * + * - Entries colliding with a built-in id, so a hand-edited settings file + * cannot shadow "Default" or "Incognito". + * - Repeated ids, which map to one partition and would otherwise appear as + * two isolated identities sharing every cookie. First entry wins. + * - `kind: "incognito"` on anything but the built-in, since persistence is + * keyed off that one id; such a profile is labelled ephemeral while its + * cookies survive restarts. + */ +export function resolveBrowserProfiles( + userProfiles: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(BUILT_IN_BROWSER_PROFILES.map((profile) => profile.id)); + const resolved = [...BUILT_IN_BROWSER_PROFILES]; + for (const profile of userProfiles) { + if (seen.has(profile.id)) continue; + seen.add(profile.id); + resolved.push(profile.kind === "persistent" ? profile : { ...profile, kind: "persistent" }); + } + return resolved; +} + +export function findBrowserProfile( + profiles: ReadonlyArray, + id: string | undefined, +): BrowserProfile | undefined { + return id === undefined ? undefined : profiles.find((profile) => profile.id === id); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e8e8de2758e5..85bfb6034e67 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,6 +28,7 @@ export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./assets.ts"; export * from "./review.ts"; +export * from "./browserProfile.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 609df9159247..25b06e866fdd 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -88,6 +88,7 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import { EnvironmentId } from "./baseSchemas.ts"; +import { BrowserProfileId } from "./browserProfile.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; @@ -974,6 +975,19 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({ export const DesktopPreviewConfigInputSchema = Schema.Struct({ environmentId: EnvironmentId, + /** + * Browser profile the partition is derived from. Derivation stays in main: + * `will-attach-webview` only prefix-checks the partition string, so a + * renderer-supplied partition could attach to a session that never had the + * UA rewrite or permission handlers installed. + */ + profileId: Schema.optional(BrowserProfileId), +}); + +export const DesktopPreviewClearDataInputSchema = Schema.Struct({ + environmentId: EnvironmentId, + /** Omit to clear every profile; otherwise only this profile's partition. */ + profileId: Schema.optional(BrowserProfileId), }); export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ @@ -1158,16 +1172,19 @@ export interface DesktopPreviewBridge { /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */ - clearCookies: () => Promise; + clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise; /** Drop the HTTP cache for the preview partition (all tabs). */ - clearCache: () => Promise; + clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise; /** * One-shot config for mounting a preview ``. Replaces three * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, * `getPickPreloadPath`) so adding a new field here only requires touching * the contract + main, not the renderer's mount logic. */ - getPreviewConfig: (environmentId: EnvironmentId) => Promise; + getPreviewConfig: ( + environmentId: EnvironmentId, + profileId?: string, + ) => Promise; setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise; /** * Activate the in-page element picker for the given tab. Resolves with diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index a1b743afc673..2df5c6401915 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -10,6 +10,7 @@ */ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { BrowserProfileId } from "./browserProfile.ts"; export const PREVIEW_URL_MAX_LENGTH = 2_048; export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; @@ -169,6 +170,12 @@ export const PreviewSessionSnapshot = Schema.Struct({ canGoForward: Schema.Boolean, /** Missing snapshots from older servers are treated as fill-panel mode. */ viewport: Schema.optional(PreviewViewportSetting), + /** + * Browser profile the tab's Chromium partition is derived from. Fixed at + * open: Electron only honours a ``'s partition before attach, so + * switching would require tearing the guest down and losing page state. + */ + profileId: Schema.optional(BrowserProfileId), updatedAt: Schema.String, }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; @@ -184,6 +191,8 @@ export const PreviewOpenInput = Schema.Struct({ * later (which the user would see as a visible reflow). */ viewport: Schema.optional(PreviewViewportSetting), + /** Omit to open under the client's configured default profile. */ + profileId: Schema.optional(BrowserProfileId), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1b6e8949e32b..7c867c212d44 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -10,6 +10,7 @@ import { ProviderOptionSelections, } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; +import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, @@ -199,6 +200,18 @@ export const ClientSettingsSchema = Schema.Struct({ browserAutoShowFloatingPreview: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), ), + /** + * User-created browser profiles. The built-in Default and Incognito profiles + * are synthesized by `resolveBrowserProfiles`, not stored here, so they + * cannot be renamed away or deleted. + */ + browserProfiles: Schema.Array(BrowserProfile).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + /** Profile new tabs open under. Falls back to Default if it no longer exists. */ + browserDefaultProfileId: BrowserProfileId.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)), + ), // Desktop-only. Boolean values from older settings files decode to their // equivalent mode and encode back as the canonical string value. confirmQuit: QuitConfirmationModeSetting.pipe( @@ -958,6 +971,8 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), + browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), + browserDefaultProfileId: Schema.optionalKey(BrowserProfileId), confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), From ca63d42d670837b918081d1fc1ebada553814b4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 14:56:45 -0700 Subject: [PATCH 11/29] refactor(shared): move the node:sqlite Effect SQL client into shared (#7272) Co-authored-by: Claude Opus 5 (1M context) --- apps/server/scripts/migrate-dev-db.test.ts | 2 +- apps/server/scripts/migrate-dev-db.ts | 2 +- apps/server/scripts/t3-sqlite-state.test.ts | 2 +- apps/server/scripts/t3-sqlite-state.ts | 2 +- apps/server/src/persistence/Layers/Sqlite.ts | 2 +- .../Migrations/016_CanonicalizeModelSelections.test.ts | 2 +- .../Migrations/019_ProjectionSnapshotLookupIndexes.test.ts | 2 +- .../024_BackfillProjectionThreadShellSummary.test.ts | 2 +- .../025_CleanupInvalidProjectionPendingApprovals.test.ts | 2 +- .../Migrations/026_CanonicalizeModelSelectionOptions.test.ts | 2 +- .../Migrations/027_028_ProviderInstanceIdColumns.test.ts | 2 +- .../029_ProjectionThreadDetailOrderingIndexes.test.ts | 2 +- .../Migrations/031_AuthAuthorizationScopes.test.ts | 2 +- .../Migrations/035_ProjectionThreadTitleRegeneration.test.ts | 2 +- .../Migrations/040_ProjectionProjectFaviconPath.test.ts | 2 +- .../Migrations/041_AuthSessionClientConnection.test.ts | 2 +- .../Migrations/042_ProjectionThreadLinkedPullRequest.test.ts | 2 +- packages/shared/package.json | 4 ++++ .../shared/src/nodeSqliteClient.test.ts | 2 +- .../shared/src/nodeSqliteClient.ts | 0 20 files changed, 22 insertions(+), 18 deletions(-) rename apps/server/src/persistence/NodeSqliteClient.test.ts => packages/shared/src/nodeSqliteClient.test.ts (97%) rename apps/server/src/persistence/NodeSqliteClient.ts => packages/shared/src/nodeSqliteClient.ts (100%) diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index ddc5b7d57f86..88308d370a6b 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -6,7 +6,7 @@ import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../src/persistence/Migrations.ts"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrateDevDb } from "./migrate-dev-db.ts"; const withDatabase = ( diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 0958f2149f45..5670f0d52b04 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -39,7 +39,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Command, Flag } from "effect/unstable/cli"; import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass()( "MigrateDevDbNotInWorktreeError", diff --git a/apps/server/scripts/t3-sqlite-state.test.ts b/apps/server/scripts/t3-sqlite-state.test.ts index d1ef1368918b..ec236f4a67c0 100644 --- a/apps/server/scripts/t3-sqlite-state.test.ts +++ b/apps/server/scripts/t3-sqlite-state.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runSqliteState } from "./t3-sqlite-state.ts"; const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* ( diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index de0402b36472..c34f4750c14c 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -15,7 +15,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Argument, Command, Flag } from "effect/unstable/cli"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; export const SqliteStateOperation = Schema.Literals(["query", "exec"]); export type SqliteStateOperation = typeof SqliteStateOperation.Type; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index ec1ffdefac0f..41d8f5baf3fd 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -18,7 +18,7 @@ type Loader = { }; const defaultSqliteClientLoaders = { bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), - node: () => import("../NodeSqliteClient.ts"), + node: () => import("@t3tools/shared/nodeSqliteClient"), } satisfies Record Promise>; const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 5c5c6138ce78..fff5a738622d 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts index 2011613a9f9a..040a9fa47b92 100644 --- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts +++ b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts index 71dfe6fd004e..49585fb36f37 100644 --- a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts index 752b1676efae..efdf88bf6c3b 100644 --- a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts index 5160b4ab34b8..558183e216cc 100644 --- a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts index 5c0d7e2a7a8d..b5e4f5cf3a22 100644 --- a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts index 4b0aa186cb4b..7078450c96ec 100644 --- a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts +++ b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts index cb50dc2c6cdb..63eba11aac71 100644 --- a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts +++ b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts index 755591201de2..0e7f54812c21 100644 --- a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts index 7fd43d9b2ece..427288125007 100644 --- a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts index 178338b78318..b19f1ce7cf20 100644 --- a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts index 1fe59df50729..02e7f2f6c59f 100644 --- a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/packages/shared/package.json b/packages/shared/package.json index 0753cc85aabb..fda7a91b1a2f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -246,6 +246,10 @@ "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" + }, + "./nodeSqliteClient": { + "types": "./src/nodeSqliteClient.ts", + "import": "./src/nodeSqliteClient.ts" } }, "scripts": { diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts similarity index 97% rename from apps/server/src/persistence/NodeSqliteClient.test.ts rename to packages/shared/src/nodeSqliteClient.test.ts index b17d3e0eb6a1..6738892a9792 100644 --- a/apps/server/src/persistence/NodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqliteClient from "./NodeSqliteClient.ts"; +import * as SqliteClient from "./nodeSqliteClient.ts"; const layer = it.layer(SqliteClient.layerMemory()); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts similarity index 100% rename from apps/server/src/persistence/NodeSqliteClient.ts rename to packages/shared/src/nodeSqliteClient.ts From 91c8d4771ccb503a9dde65190b87db642df0a6ea Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 18:24:52 -0400 Subject: [PATCH 12/29] feat(web): add opt-in panel animations (#8830) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/AppSidebarLayout.tsx | 11 +- apps/web/src/components/ChatView.tsx | 226 ++++++++++++------ apps/web/src/components/RightPanelSheet.tsx | 2 + apps/web/src/components/RightPanelTabs.tsx | 2 + .../src/components/WorkspacePageHeader.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 38 ++- apps/web/src/components/chat/ChatHeader.tsx | 20 ++ .../components/preview/PreviewPanelShell.tsx | 45 +++- .../settings/PanelAnimationsPreview.tsx | 52 ++++ .../components/settings/SettingsPanels.tsx | 69 ++++++ .../settings/settingsSearch.test.ts | 1 + .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/components/ui/sheet.tsx | 22 +- apps/web/src/components/ui/sidebar.tsx | 9 +- apps/web/src/panelAnimations.ts | 88 +++++++ apps/web/src/routes/_chat.pull-requests.tsx | 72 ++++-- docs/user/thread-sidebar.md | 7 + packages/contracts/src/settings.test.ts | 16 ++ packages/contracts/src/settings.ts | 16 ++ 20 files changed, 598 insertions(+), 106 deletions(-) create mode 100644 apps/web/src/components/settings/PanelAnimationsPreview.tsx create mode 100644 apps/web/src/panelAnimations.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 4766b7a3439c..1c02a45026bc 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -41,6 +41,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + panelAnimationDurationMs: 0, planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3d9e0e82e519..1780c8b9acb8 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -17,6 +17,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import { usePanelAnimationSettings } from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; @@ -148,6 +149,8 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); + const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = + usePanelAnimationSettings(); // Settings routes show the settings nav in place of whichever thread // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); @@ -175,6 +178,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }); const sidebarProviderStyle = { "--sidebar-width": `${sidebarWidth}px`, + "--panel-animation-duration": `${panelAnimationDurationMs}ms`, ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), @@ -218,7 +222,12 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate, pathname]); return ( - + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); + const visible = active && terminalUiState.terminalOpen; const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId, @@ -1063,41 +1065,51 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra [onAddTerminalContext, visible], ); - if (!project || !terminalUiState.terminalOpen || !cwd) { + if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { return null; } return ( -
- +
+
+ +
); }); @@ -1791,8 +1803,6 @@ function ChatViewContent(props: ChatViewProps) { ); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const sidebarPrRefreshKeyRef = useRef(null); - const activeFileSurface = - activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; const resolvePreviewRuntimeTabId = useMemo( @@ -1820,6 +1830,34 @@ function ChatViewContent(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; + const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = + usePanelAnimationSettings(); + const activeTerminalDrawerPresence = usePanelPresence( + Boolean(activeThreadKey && terminalUiState.terminalOpen), + true, + panelAnimationsActive, + activeThreadKey, + panelAnimationDurationMs, + ); + const rightPanelPresenceValue = useMemo( + () => ({ + activeSurface: activeRightPanelSurface, + surfaces: rightPanelState.surfaces, + }), + [activeRightPanelSurface, rightPanelState.surfaces], + ); + const rightPanelPresence = usePanelPresence( + rightPanelOpen && activeThreadRef !== null, + rightPanelPresenceValue, + panelAnimationsActive, + activeThreadKey, + panelAnimationDurationMs, + ); + const rightPanelPresent = rightPanelPresence.present; + const rightPanelControlsInPanel = + rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen); + const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; + const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -1882,7 +1920,7 @@ function ChatViewContent(props: ChatViewProps) { currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), + activeThreadTerminalOpen: activeTerminalDrawerPresence.present, maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -1890,7 +1928,7 @@ function ChatViewContent(props: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); + }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = useMemo( () => @@ -7095,29 +7133,39 @@ function ChatViewContent(props: ChatViewProps) { const panelLayoutControls = (
- {rightPanelOpen && !shouldUseRightPanelSheet ? ( - + {!shouldUseRightPanelSheet ? ( + + + ) : null} - {panelToggleControls} +
{panelToggleControls}
); const rightPanelContent = activeThreadRef ? ( - activeRightPanelSurface?.kind === "preview" ? ( + renderedRightPanelSurface?.kind === "preview" ? ( { @@ -7125,10 +7173,10 @@ function ChatViewContent(props: ChatViewProps) { }} /> - ) : activeRightPanelSurface?.kind === "terminal" ? ( + ) : renderedRightPanelSurface?.kind === "terminal" ? ( - ) : activeRightPanelSurface?.kind === "diff" ? ( + ) : renderedRightPanelSurface?.kind === "diff" ? ( - ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( + ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - ) : activeRightPanelSurface?.kind === "pull-request" ? ( + ) : renderedRightPanelSurface?.kind === "pull-request" ? ( // No onClose: the surface tab's own X owns closing here, and a second X in the header // would be the same action twice. The thread context also drops the checkout button, so it // is only right for the thread's own pull request, whose branch is already under the // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. - ) : activeRightPanelSurface?.kind === "agents" ? ( + ) : renderedRightPanelSurface?.kind === "agents" ? ( - ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && + ) : (renderedRightPanelSurface?.kind === "files" || + renderedRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( @@ -7213,14 +7262,25 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} relativePath={ - activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null + renderedRightPanelSurface.kind === "file" + ? renderedRightPanelSurface.relativePath + : null + } + revealLine={ + renderedRightPanelSurface.kind === "file" + ? (renderedRightPanelSurface.revealLine ?? null) + : null + } + revealRequestId={ + renderedRightPanelSurface.kind === "file" + ? renderedRightPanelSurface.revealRequestId + : 0 } - revealLine={activeFileSurface?.revealLine ?? null} - revealRequestId={activeFileSurface?.revealRequestId ?? 0} onOpenFile={openFileSurface} onPendingChange={handleFilePendingChange} selectedFilePending={ - activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id) + renderedRightPanelSurface.kind === "file" && + pendingFileSurfaceIds.has(renderedRightPanelSurface.id) } workspaceMutationId={workspaceMutationId} /> @@ -7235,7 +7295,6 @@ function ChatViewContent(props: ChatViewProps) { return (
- {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
- {!rightPanelOpen ? panelLayoutControls : null} + {!shouldUseRightPanelSheet || !rightPanelControlsInPanel ? panelLayoutControls : null} - {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? ( ) : null} - {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( - + {rightPanelPresent && shouldUseRightPanelSheet && activeThreadRef ? ( + {panelToggleControls}
} - surfaces={rightPanelState.surfaces} - activeSurfaceId={activeRightPanelSurface?.id ?? null} + layoutControls={ + rightPanelOpen ? ( +
{panelToggleControls}
+ ) : null + } + surfaces={renderedRightPanelSurfaces} + activeSurfaceId={renderedRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index ebc4aa0a698f..e3468034396b 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -4,6 +4,7 @@ import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout"; import { Sheet, SheetPopup } from "./ui/sheet"; export function RightPanelSheet(props: { + animationDurationMs: number; children: ReactNode; open: boolean; onClose: () => void; @@ -18,6 +19,7 @@ export function RightPanelSheet(props: { }} > diff --git a/apps/web/src/components/WorkspacePageHeader.tsx b/apps/web/src/components/WorkspacePageHeader.tsx index cd8a96273c00..5d98b80760ac 100644 --- a/apps/web/src/components/WorkspacePageHeader.tsx +++ b/apps/web/src/components/WorkspacePageHeader.tsx @@ -16,7 +16,7 @@ export function WorkspacePageHeader({ return (
(null); const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); + const composerFooterControlsRef = useRef(null); const composerSurfaceRef = useRef(null); const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); @@ -1829,6 +1835,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); setIsComposerFooterCompact(initialCompactness.footerCompact); if (typeof ResizeObserver === "undefined") return; + const footerControls = composerFooterControlsRef.current; + const stopFooterControlsFade = footerControls + ? observeResponsiveBreakpointFade({ + target: footerControls, + container: composerForm, + active: panelAnimationsActive, + durationMs: panelAnimationDurationMs, + breakpoint: { + value: composerFooterHasWideActions + ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX + : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, + unit: "px", + }, + }) + : undefined; const observer = new ResizeObserver(() => { const nextCompactness = measureFooterCompactness(); @@ -1845,8 +1866,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) observer.observe(composerForm); return () => { observer.disconnect(); + stopFooterControlsFade?.(); }; - }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]); + }, [ + activeThreadId, + composerFooterActionLayoutKey, + composerFooterHasWideActions, + isComposerApprovalState, + isComposerCollapsedMobile, + panelAnimationDurationMs, + panelAnimationsActive, + ]); // ------------------------------------------------------------------ // Image persist effect @@ -4223,7 +4253,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showMobilePendingAnswerActions && "hidden sm:flex", )} > -
+
{noProviderAvailable ? ( + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8137114dcd93..51423cf996e6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -24,6 +24,7 @@ import { MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, MAX_INTERFACE_FONT_SIZE, + MAX_PANEL_ANIMATION_DURATION_MS, MAX_PROMPT_FONT_SIZE, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, @@ -31,6 +32,7 @@ import { MIN_APPEARANCE_CONTRAST, MIN_GLASS_OPACITY, MIN_INTERFACE_FONT_SIZE, + MIN_PANEL_ANIMATION_DURATION_MS, MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, @@ -152,6 +154,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; +import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -493,6 +496,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Contrast"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), + ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs + ? ["Panel animations"] + : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode ? ["Environment identification"] @@ -593,6 +599,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizePrompt, settings.fontSizeTerminal, settings.glassOpacity, + settings.panelAnimationDurationMs, settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, @@ -680,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) { contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, + panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, @@ -1035,6 +1043,13 @@ export function AppearanceSettingsPanel() { "--settings-slider-progress": `${appearanceContrastRatio * 100}%`, "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`, } as CSSProperties; + const panelAnimationDurationRatio = + (settings.panelAnimationDurationMs - MIN_PANEL_ANIMATION_DURATION_MS) / + (MAX_PANEL_ANIMATION_DURATION_MS - MIN_PANEL_ANIMATION_DURATION_MS); + const panelAnimationDurationSliderStyle = { + "--settings-slider-progress": `${panelAnimationDurationRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - panelAnimationDurationRatio}rem`, + } as CSSProperties; return ( @@ -1192,6 +1207,60 @@ export function AppearanceSettingsPanel() { ) : null} + + + +
+ + {settings.panelAnimationDurationMs} ms + + { + const panelAnimationDurationMs = Number(event.currentTarget.value); + if ( + Number.isInteger(panelAnimationDurationMs) && + panelAnimationDurationMs >= MIN_PANEL_ANIMATION_DURATION_MS && + panelAnimationDurationMs <= MAX_PANEL_ANIMATION_DURATION_MS + ) { + updateSettings({ panelAnimationDurationMs }); + } + }} + step={25} + style={panelAnimationDurationSliderStyle} + type="range" + value={settings.panelAnimationDurationMs} + /> +
+
+ } + resetAction={ + settings.panelAnimationDurationMs !== + DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ( + + updateSettings({ + panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, + }) + } + /> + ) : null + } + /> + + ); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index a84a2e925b40..362fe52738ed 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -51,6 +51,7 @@ describe("searchSettings", () => { it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); + expect(searchSettings("panel animations").map((item) => item.id)).toEqual(["panel-animations"]); expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme"); const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); try { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 20aea7d3f77e..f6bba760d1c3 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -94,6 +94,11 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["transparent transparency solid menus dialogs composer"], }, + { + id: "panel-animations", + title: "Panel animations", + to: "/settings/appearance", + }, { id: "environment-identification", title: "Environment identification", diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 9d0436b661da..2e3cb060df04 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -2,6 +2,7 @@ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; +import type { CSSProperties } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -62,18 +63,34 @@ function SheetPopup({ children, showCloseButton = true, keepMounted = false, + transitionDurationMs, side = "right", variant = "default", + style, ...props }: SheetPrimitive.Popup.Props & { showCloseButton?: boolean; keepMounted?: boolean; + transitionDurationMs?: number; side?: "right" | "left" | "top" | "bottom"; variant?: "default" | "inset"; }) { + const transitionStyle = + transitionDurationMs === undefined + ? undefined + : ({ transitionDuration: `${transitionDurationMs}ms` } satisfies CSSProperties); + const instant = transitionDurationMs === 0; + return ( - + {children} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index ce6e4cc78ca9..624798e19f54 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -281,7 +281,8 @@ function Sidebar({ {/* This is what handles the sidebar gap on desktop */}
diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index 032076c0b74b..b4c4d2217565 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { formatContextWindowCompactionMessage, - hasAvailableClaudeCompactionProvider, + hasAvailableCompactionProvider, hasDismissedResumeCompaction, resolveContextWindowModelDisplayName, shouldOfferResumeCompaction, @@ -25,12 +25,12 @@ function claudeProvider(input: { auth: { status: "authenticated" }, checkedAt: "2026-08-24T12:00:00.000Z", models: [], - slashCommands: [], + slashCommands: [{ name: "compact", description: "" }], skills: [], }; } -describe("hasAvailableClaudeCompactionProvider", () => { +describe("hasAvailableCompactionProvider", () => { const originalInstanceId = ProviderInstanceId.make("claude_original"); it("rejects a fallback in a different locked continuation group", () => { @@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => { ]); expect( - hasAvailableClaudeCompactionProvider({ + hasAvailableCompactionProvider({ providers, + driverKind: ProviderDriverKind.make("claudeAgent"), instanceId: originalInstanceId, lockedInstanceId: originalInstanceId, }), @@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => { ]); expect( - hasAvailableClaudeCompactionProvider({ + hasAvailableCompactionProvider({ providers, + driverKind: ProviderDriverKind.make("claudeAgent"), instanceId: originalInstanceId, lockedInstanceId: originalInstanceId, }), diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 8e46c16e9a09..be3dacb05e92 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,4 +1,4 @@ -import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, isClaudeResumeCompactionQuestion, @@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; -export function hasAvailableClaudeCompactionProvider(input: { +export function providerSupportsManualCompaction( + provider: ProviderInstanceEntry | null | undefined, +): boolean { + return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false; +} + +export function hasAvailableCompactionProvider(input: { readonly providers: ReadonlyArray; + readonly driverKind: ProviderDriverKind; readonly instanceId: ProviderInstanceId | null; readonly lockedInstanceId: ProviderInstanceId | null; }): boolean { - const claudeProviders = input.providers.filter( - (provider) => provider.driverKind === "claudeAgent", + const driverProviders = input.providers.filter( + (provider) => provider.driverKind === input.driverKind, ); const lockedContinuationGroupKey = input.lockedInstanceId - ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId) + ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId) ?.continuationGroupKey : undefined; const compatibleProviders = lockedContinuationGroupKey - ? claudeProviders.filter( + ? driverProviders.filter( (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, ) - : claudeProviders; + : driverProviders; - return ( - resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !== - undefined + return providerSupportsManualCompaction( + resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined), ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 323dac4f1114..e417ef81ccfb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -919,7 +919,7 @@ describe("MessagesTimeline", () => { entry: { id: "work-1", createdAt: "2026-03-17T19:12:28.000Z", - label: "Context compacted", + label: "Compacted context 899K → 19K tokens", tone: "info", }, }, @@ -927,7 +927,7 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain("Context compacted"); + expect(markup).toContain("Compacted context 899K → 19K tokens"); }); it("summarizes changed files in one line", () => { diff --git a/docs/user/composer.md b/docs/user/composer.md index eea5a658a337..ec3c6384d25c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -196,6 +196,8 @@ such as System, Personal, Project, or App. On mobile, these menus are available on the **New task** screen before you start a thread. They use the skills and commands from the selected environment and provider. +In an existing thread, send `/compact` to reduce context usage. Web and desktop also offer this action from the context meter, and the work log records token counts when the provider reports them. + By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the same `$name` skill token to your message. The original skill name remains searchable. If the provider diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 31ffc6bfc0b6..f4e22150aef3 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -298,6 +298,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; const ThreadStateChangedPayload = Schema.Struct({ state: RuntimeThreadState, + beforeTokens: Schema.optional(NonNegativeInt), + afterTokens: Schema.optional(NonNegativeInt), detail: Schema.optional(Schema.Unknown), }); export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; From fb93902ee24d4ba380508df33d25478d3baf7a72 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 18:51:21 -0400 Subject: [PATCH 16/29] feat(web): add proactive panels (#9276) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../settings/DesktopClientSettings.test.ts | 1 + .../web/src/components/ChatView.logic.test.ts | 47 +++++++ apps/web/src/components/ChatView.logic.ts | 22 +++ apps/web/src/components/ChatView.tsx | 127 +++++++++++++++++- .../components/settings/SettingsPanels.tsx | 31 +++++ .../src/components/settings/settingsSearch.ts | 6 + docs/user/source-control.md | 2 + packages/contracts/src/settings.test.ts | 9 ++ packages/contracts/src/settings.ts | 2 + 9 files changed, 244 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1c02a45026bc..0bddf23e46e6 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -43,6 +43,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, panelAnimationDurationMs: 0, planModeEnabled: false, + proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 290e3435abca..9fe03c1c980f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -40,6 +40,8 @@ import { codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, + shouldOpenProactivePullRequest, + shouldOpenProactiveTurnDiff, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, @@ -83,6 +85,51 @@ describe("agent browser close confirmation", () => { }); }); +describe("proactive panels", () => { + it("opens a pull request only after a newly observed link appears", () => { + expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); + expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true); + expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false); + expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); + }); + + it("opens the diff only when the observed running turn settles", () => { + const turnId = TurnId.make("turn-1"); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: undefined, + runningTurnId: null, + settledTurnId: turnId, + turnCompleted: true, + }), + ).toBe(false); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: turnId, + runningTurnId: null, + settledTurnId: turnId, + turnCompleted: true, + }), + ).toBe(true); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: turnId, + runningTurnId: TurnId.make("turn-2"), + settledTurnId: turnId, + turnCompleted: true, + }), + ).toBe(false); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: turnId, + runningTurnId: null, + settledTurnId: turnId, + turnCompleted: false, + }), + ).toBe(false); + }); +}); + describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e9a18d016877..087378706338 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -78,6 +78,28 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +export function shouldOpenProactivePullRequest( + previousTargetKey: string | null | undefined, + targetKey: string | null, +): boolean { + return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey; +} + +export function shouldOpenProactiveTurnDiff(input: { + previousRunningTurnId: TurnId | null | undefined; + runningTurnId: TurnId | null; + settledTurnId: TurnId | null; + turnCompleted: boolean; +}): boolean { + return ( + input.previousRunningTurnId !== undefined && + input.previousRunningTurnId !== null && + input.runningTurnId === null && + input.turnCompleted && + input.settledTurnId === input.previousRunningTurnId + ); +} + export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6afaedad5bf2..ad43381c645e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -361,6 +361,8 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, + shouldOpenProactivePullRequest, + shouldOpenProactiveTurnDiff, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -1766,6 +1768,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -3665,7 +3668,16 @@ function ChatViewContent(props: ChatViewProps) { ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; + const linkedThreadPullRequest = isServerThread + ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) + : (activeThread?.linkedPullRequest ?? null); + const linkedThreadPullRequestKey = linkedThreadPullRequest + ? JSON.stringify([ + linkedThreadPullRequest.projectId, + linkedThreadPullRequest.repository, + linkedThreadPullRequest.number, + ]) + : null; const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( @@ -3708,6 +3720,117 @@ function ChatViewContent(props: ChatViewProps) { }, [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], ); + const proactiveTurnObservationRef = useRef<{ + threadKey: string; + runningTurnId: TurnId | null; + } | null>(null); + const proactivePullRequestObservationRef = useRef<{ + threadKey: string; + targetKey: string | null; + } | null>(null); + + useEffect(() => { + if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { + proactiveTurnObservationRef.current = null; + return; + } + if (!clientSettingsHydrated || threadDetailLoading) { + return; + } + + const previousObservation = proactiveTurnObservationRef.current; + const observingSameThread = previousObservation?.threadKey === activeThreadKey; + const previousRunningTurnId = observingSameThread + ? previousObservation.runningTurnId + : undefined; + const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null; + const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({ + previousRunningTurnId, + runningTurnId: activeRunningTurnId, + settledTurnId, + turnCompleted: activeLatestTurn?.state === "completed", + }) + ? settledTurnId + : null; + const eligibleCompletion = + settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null; + const checkpointReady = + eligibleCompletion && + activeThread?.checkpoints.some((checkpoint) => checkpoint.turnId === newlyCompletedTurnId) === + true; + const shouldOpenTurn = checkpointReady && gitStatusQuery.data?.isRepo === true; + const shouldDeferCompletion = + eligibleCompletion && !shouldOpenTurn && gitStatusQuery.data?.isRepo !== false; + proactiveTurnObservationRef.current = { + threadKey: activeThreadKey, + runningTurnId: shouldDeferCompletion ? (previousRunningTurnId ?? null) : activeRunningTurnId, + }; + if (!shouldOpenTurn || newlyCompletedTurnId === null) return; + + useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId); + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + }, [ + activeThread?.checkpoints, + activeLatestTurn?.turnId, + activeLatestTurn?.state, + activeRunningTurnId, + activeThreadKey, + activeThreadRef, + clientSettingsHydrated, + gitStatusQuery.data?.isRepo, + isServerThread, + latestTurnSettled, + onDiffPanelOpen, + settings.proactivePanelsEnabled, + shouldUseRightPanelSheet, + threadDetailLoading, + ]); + + useEffect(() => { + if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { + proactivePullRequestObservationRef.current = null; + return; + } + if (!clientSettingsHydrated || threadDetailLoading) { + return; + } + + const previousObservation = proactivePullRequestObservationRef.current; + const observingSameThread = previousObservation?.threadKey === activeThreadKey; + const previousTargetKey = observingSameThread ? previousObservation.targetKey : undefined; + const newlyLinkedPullRequest = shouldOpenProactivePullRequest( + previousTargetKey, + linkedThreadPullRequestKey, + ); + const eligibleLink = + settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyLinkedPullRequest; + const shouldOpenLink = + eligibleLink && + pullRequestsCapabilityKnown && + supportsPullRequests && + linkedThreadPullRequest !== null; + const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown; + proactivePullRequestObservationRef.current = { + threadKey: activeThreadKey, + targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey, + }; + if (!shouldOpenLink || linkedThreadPullRequest === null) return; + + useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); + }, [ + activeThreadKey, + activeThreadRef, + clientSettingsHydrated, + isServerThread, + linkedThreadPullRequest, + linkedThreadPullRequestKey, + pullRequestsCapabilityKnown, + settings.proactivePanelsEnabled, + shouldUseRightPanelSheet, + supportsPullRequests, + threadDetailLoading, + ]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -4612,8 +4735,6 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - // The server-projected settled state keeps the banner and sidebar in sync. - const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const activeComposerTasksProgress = useMemo(() => { if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { return null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 51423cf996e6..2fdc985a84d8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled + ? ["Proactive panels"] + : []), ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), @@ -588,6 +591,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, + settings.proactivePanelsEnabled, settings.environmentIdentificationMode, settings.contextWindowMeterEnabled, settings.fontFamilyCode, @@ -683,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, @@ -2206,6 +2211,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ proactivePanelsEnabled: Boolean(checked) }) + } + aria-label="Proactive panels" + /> + } + /> + { }); }); +describe("ClientSettings proactive panels", () => { + it("is opt-in and accepts client-local updates", () => { + expect(decodeClientSettings({}).proactivePanelsEnabled).toBe(false); + expect(decodeClientSettingsPatch({ proactivePanelsEnabled: true }).proactivePanelsEnabled).toBe( + true, + ); + }); +}); + describe("ClientSettings quit confirmation", () => { it("defaults to hold", () => { expect(decodeClientSettings({}).confirmQuit).toBe("hold"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9eeabba67b74..8de2b73e2ad9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -299,6 +299,7 @@ export const ClientSettingsSchema = Schema.Struct({ // Legacy context window meter. The composer hides it by default; users who // still want the old usage indicator can restore it from Settings. contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the @@ -1029,6 +1030,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), + proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), From b59b7d0af9536a2d61ddfcf8d33420b890b2faca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 15:56:28 -0700 Subject: [PATCH 17/29] fix(web): unify control sizing across settings pages (#9281) Co-authored-by: Claude Code --- .../BranchToolbarBranchSelector.tsx | 2 +- .../web/src/components/ServerUpdateAction.tsx | 6 ++- .../settings/AddProviderInstanceDialog.tsx | 13 ++--- .../settings/ConnectionsSettings.tsx | 9 ++-- .../settings/DiagnosticsSettings.tsx | 8 +-- .../components/settings/FontFamilyPicker.tsx | 8 ++- .../settings/IntegrationsSettings.tsx | 16 ++++-- .../settings/KeybindingsSettings.tsx | 30 ++++++------ .../settings/ProjectSettingsPanel.tsx | 17 ++++--- .../settings/ProviderInstanceCard.tsx | 11 +++-- .../settings/ProviderModelsSection.tsx | 9 ++-- .../settings/ProviderSettingsForm.tsx | 4 +- .../settings/ProviderSettingsPanel.tsx | 6 +-- .../components/settings/SettingsPanels.tsx | 49 +++++++++++++------ .../settings/SharedSettingsMismatchAlert.tsx | 2 +- .../settings/SourceControlSettings.tsx | 14 ++---- .../settings/SourceControlWritingSettings.tsx | 15 ++++-- .../components/settings/ThemeEditorPanel.tsx | 2 + .../components/settings/settingsLayout.tsx | 14 ++++++ apps/web/src/components/ui/switch.tsx | 12 ++++- 20 files changed, 154 insertions(+), 93 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 5fcad2f741db..42b94b538086 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -850,7 +850,7 @@ export function BranchToolbarBranchSelector({ onStartFromOriginChange(Boolean(checked))} /> diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 4647eb541566..71b974416dd3 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -83,6 +83,7 @@ export function ServerUpdateAction({ targetVersion, label = "Update", variant = "outline", + size = "xs", }: { readonly environmentId: EnvironmentId; readonly serverLabel: string; @@ -95,6 +96,7 @@ export function ServerUpdateAction({ readonly targetVersion: string; readonly label?: string; readonly variant?: ComponentProps["variant"]; + readonly size?: ComponentProps["size"]; }) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const continueThreadsAfterServerUpdate = useClientSettings( @@ -185,14 +187,14 @@ export function ServerUpdateAction({ if (selfUpdate === null) { const command = manualServerUpdateCommand(targetVersion); return ( - ); } return ( - ); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 158908b5e942..9671755f8bea 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -380,9 +380,9 @@ export function AddProviderInstanceDialog({ {accentColor ? ( {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( - + ) : ( - + )}
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b33a81b7b7a4..b487fbb8bd19 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -785,7 +785,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ Done {canCopyToClipboard ? ( - ) : null} @@ -2784,7 +2784,7 @@ export function ConnectionsSettings() { status={{desktopWslError}} control={ } /> @@ -1129,13 +1129,13 @@ export function DiagnosticsSettingsPanel() { - + } /> diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index 6cc28567cb2e..3fa5087e9dae 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -11,6 +11,7 @@ import { ComboboxPopup, ComboboxTrigger, } from "../ui/combobox"; +import { selectTriggerVariants } from "../ui/select"; const DEFAULT_FONT_VALUE = "__default__"; @@ -203,14 +204,11 @@ export function FontFamilyPicker({ void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false }); }} > - + {selectedFamily.length === 0 ? defaultFamily : selectedFamily} - +
diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index af08091a520a..62a17b9368e6 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -354,7 +354,7 @@ function BrowserZoomSetting({ disabled }: { readonly disabled: boolean }) { if (next !== undefined) updateSettings({ browserDefaultZoomFactor: next }); }} > - + {zoomLabel(zoomFactor)} @@ -396,7 +396,11 @@ function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean }) } }} > - + {APPEARANCE_LABELS[appearance]} @@ -441,7 +445,11 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo } }} > - + {frameRate} fps @@ -706,7 +714,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { render={ } /> @@ -168,7 +168,7 @@ function ExpandableHeaderSearch({ placeholder="Search keybindings" aria-label="Search keybindings" className="w-44 [&_[data-slot=input]]:pl-7" - size="compact" + size="sm" />
); @@ -850,7 +850,7 @@ function KeybindingKeyControl({ <> {isDirty ? ( @@ -1514,12 +1514,12 @@ export function KeybindingsSettingsPanel() { render={ } /> @@ -1530,13 +1530,13 @@ export function KeybindingsSettingsPanel() { render={ } /> diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 5d63f5fd4584..1f0aba37586c 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -106,6 +106,7 @@ import { } from "../WorkspaceBreadcrumb"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, SettingsPageContainer, SettingsRow, @@ -803,6 +804,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { control={ -
diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 988ac9160412..02f40fd6d5c9 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -219,7 +219,7 @@ function ProviderSettingsFieldRow({ )}
{field.control === "switch" ? ( - + @@ -245,6 +245,7 @@ function ProviderSettingsFieldRow({ void refreshProviders()} @@ -892,12 +892,12 @@ export function EnvironmentProviderSettings({ setIsAddInstanceDialogOpen(true)} aria-label="Add provider" > - + } /> diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2fdc985a84d8..3c5f6d7037a6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -146,6 +146,7 @@ import { } from "./SettingsPanels.logic"; import { PolicyTooltip, + SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, SettingsPageContainer, SettingsRow, @@ -391,7 +392,7 @@ function AboutVersionSection() { - + {HOSTED_APP_CHANNEL_LABEL} @@ -805,7 +807,11 @@ function BackgroundActivityAdvancedDialog({ } }} > - + {BACKGROUND_ACTIVITY_PROFILE_LABELS[activeProfile]} @@ -1194,7 +1200,11 @@ export function AppearanceSettingsPanel() { } }} > - + {ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]} @@ -1701,6 +1711,7 @@ function FontFamilySettingsRow({ /> ) : ( - + {size.value} px @@ -1806,6 +1817,7 @@ function AutoSettleDaysInput({ return ( - + {TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]} @@ -2358,7 +2370,11 @@ export function GeneralSettingsPanel() { } }} > - + {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[backgroundActivityProfileOption]} @@ -2432,7 +2448,7 @@ export function GeneralSettingsPanel() { } }} > - + {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"} @@ -2500,6 +2516,7 @@ export function GeneralSettingsPanel() { } control={ updateSettings({ addProjectBaseDirectory: next })} @@ -2611,7 +2628,11 @@ export function GeneralSettingsPanel() { } }} > - + {QUIT_CONFIRMATION_MODE_LABELS[settings.confirmQuit]} @@ -2652,7 +2673,7 @@ export function GeneralSettingsPanel() { instanceEntries={textGenerationModelInstanceEntries} modelOptionsByInstance={textGenerationModelOptionsByInstance} triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} onInstanceModelChange={(instanceId, model) => { updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( @@ -2681,7 +2702,7 @@ export function GeneralSettingsPanel() { allowPromptInjectedEffort={false} planModeEnabled={settings.planModeEnabled} triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} onModelOptionsChange={(nextOptions) => { updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( @@ -2716,7 +2737,7 @@ export function GeneralSettingsPanel() { {...searchableSetting("diagnostics")} description={diagnosticsDescription} control={ - } @@ -2924,8 +2945,8 @@ export function ArchivedThreadsPanel() { diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 51f3e7bfa932..480ff26cc7f7 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -312,7 +312,7 @@ function DiscoveryItemRow({
{hasDetails ? ( @@ -533,13 +527,13 @@ export function SourceControlSettingsPanel() { - + } /> diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 33da931b9db5..47d0324c7f93 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -20,7 +20,12 @@ import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; -import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingResetButton, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; const MODE_OPTIONS: Record = @@ -105,7 +110,11 @@ export function SourceControlWritingSettingsSection() { }); }} > - + {MODE_OPTIONS[style.mode].label} @@ -185,7 +194,7 @@ export function SourceControlWritingSettingsSection() { instanceEntries={instanceEntries} modelOptionsByInstance={modelOptionsByInstance} triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} triggerAriaLabel="Source control writer model" onInstanceModelChange={(instanceId, model) => { updateSettings({ diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 9ac264cd00d3..4e85bc8806e8 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -928,6 +928,7 @@ export function ThemeEditorPanel({ Theme name { setName(event.currentTarget.value); // Most save failures are name collisions; retyping is the fix, so @@ -949,6 +950,7 @@ export function ThemeEditorPanel({ + + {/* + Same choice the tab bar's "+" menu offers: the card opens the + default profile, the chevron picks another. Only worth showing + once there is something to choose between. + */} + {action.label === "Browser" && props.browserProfiles.length > 1 ? ( + + + } + > + + + + {props.browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)} + > + {profile.name} + + ))} + + + ) : null} +
) : (
Date: Wed, 2 Sep 2026 18:58:07 -0400 Subject: [PATCH 19/29] Revert "feat(providers): add context compaction across harnesses" (#9284) --- .../threads/use-composer-command-menu.ts | 4 +- ...ProviderSessionStartup.integration.test.ts | 1 - .../Layers/CheckpointReactor.test.ts | 1 - .../Layers/ProjectionPipeline.ts | 15 --- .../Layers/ProviderCommandReactor.test.ts | 24 ---- .../Layers/ProviderCommandReactor.ts | 117 ++++------------- .../Layers/ProviderRuntimeIngestion.test.ts | 5 +- .../Layers/ProviderRuntimeIngestion.ts | 11 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 13 +- .../src/provider/Layers/ClaudeAdapter.ts | 41 ++---- .../src/provider/Layers/ClaudeProvider.ts | 9 +- .../src/provider/Layers/CodexAdapter.test.ts | 43 ------- .../src/provider/Layers/CodexAdapter.ts | 24 +--- .../src/provider/Layers/CodexProvider.ts | 2 - .../provider/Layers/CodexSessionRuntime.ts | 5 - .../src/provider/Layers/CursorProvider.ts | 2 - .../src/provider/Layers/GrokProvider.ts | 2 - .../provider/Layers/OpenCodeAdapter.test.ts | 39 ------ .../src/provider/Layers/OpenCodeAdapter.ts | 81 ------------ .../src/provider/Layers/OpenCodeProvider.ts | 2 - .../provider/Layers/ProviderRegistry.test.ts | 14 ++- .../provider/Layers/ProviderService.test.ts | 52 -------- .../src/provider/Layers/ProviderService.ts | 119 ++---------------- .../Layers/ProviderSessionReaper.test.ts | 1 - .../src/provider/Services/ProviderAdapter.ts | 5 - .../src/provider/Services/ProviderService.ts | 5 - apps/server/src/provider/providerSnapshot.ts | 5 - .../serverRuntimeStartup.reconcile.test.ts | 1 - apps/web/src/components/ChatView.tsx | 40 +++--- apps/web/src/components/chat/ChatComposer.tsx | 31 ++--- .../chat/ContextWindowMeter.logic.test.ts | 12 +- .../chat/ContextWindowMeter.logic.ts | 26 ++-- .../components/chat/MessagesTimeline.test.tsx | 4 +- docs/user/composer.md | 2 - packages/contracts/src/providerRuntime.ts | 2 - 35 files changed, 114 insertions(+), 646 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 57985ce10f10..966ceedeec85 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -127,8 +127,8 @@ export function useComposerCommandMenu({ // Codex feedback uploads an existing thread's session and logs. if ( !hasThread && - (command.name === "compact" || - (selectedProviderStatus?.driver === "codex" && command.name === "feedback")) + selectedProviderStatus?.driver === "codex" && + command.name === "feedback" ) { continue; } diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 76c18486b55f..434d9902777a 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -110,7 +110,6 @@ const startupDependencies = Layer.mergeAll( Layer.succeed(ProviderService.ProviderService, { startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), - compactThread: () => Effect.die("unused"), interruptTurn: () => Effect.die("unused"), respondToRequest: () => Effect.die("unused"), respondToUserInput: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 855797108e6d..ca4cb7afd9ab 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -107,7 +107,6 @@ function createProviderServiceHarness( const service: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), - compactThread: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 01c646ee15b5..3de33474d205 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1275,21 +1275,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - case "thread.activity-appended": - if (event.payload.activity.kind !== "provider.turn.start.failed") return; - const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId( - event.payload, - ); - if ( - Option.isNone(pendingTurnStart) || - String(pendingTurnStart.value.messageId) !== - extractActivityRequestId(event.payload.activity.payload) - ) { - return; - } - yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload); - return; - case "thread.session-set": { const turnId = event.payload.session.activeTurnId; if (turnId === null || event.payload.session.status !== "running") { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3c049029e52b..42db044c28d6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -254,7 +254,6 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); - const compactThread = vi.fn((_: ThreadId) => Effect.void); const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); @@ -340,7 +339,6 @@ describe("ProviderCommandReactor", () => { const service: ProviderServiceShape = { startSession: startSession as ProviderServiceShape["startSession"], sendTurn: sendTurn as ProviderServiceShape["sendTurn"], - compactThread, interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], @@ -535,7 +533,6 @@ describe("ProviderCommandReactor", () => { readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), startSession, sendTurn, - compactThread, interruptTurn, respondToRequest, respondToUserInput, @@ -635,27 +632,6 @@ describe("ProviderCommandReactor", () => { }), ); - effectIt.effect("rejects /compact without conversation context", () => - Effect.gen(function* () { - const harness = yield* Effect.promise(() => createHarness()); - yield* harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-empty-compact"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-empty-compact"), - role: "user", - text: "/compact", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: "2026-01-01T00:00:00.000Z", - }); - yield* Effect.promise(() => harness.drain()); - expect(harness.compactThread).not.toHaveBeenCalled(); - }), - ); effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f7d213fede2b..57edb60ff715 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -30,10 +30,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; -import { - ProviderAdapterRequestError, - ProviderAdapterValidationError, -} from "../../provider/Errors.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -53,7 +50,6 @@ import { import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); -const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderDriverKind = Schema.is(ProviderDriverKind); type ProviderIntentEvent = Extract< @@ -76,10 +72,6 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined return normalized && normalized.length > 0 ? normalized : undefined; } -const isCompactCommandMessage = (message: ThreadTitleMessage): boolean => - message.role === "user" && - (message.attachments?.length ?? 0) === 0 && - message.text.trim().toLowerCase() === "/compact"; function mapProviderSessionStatusToOrchestrationStatus( status: "connecting" | "ready" | "running" | "error" | "closed", ): OrchestrationSession["status"] { @@ -340,7 +332,6 @@ const make = Effect.gen(function* () { ); const threadModelSelections = new Map(); - const compactingThreadIds = new Set(); const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; @@ -384,11 +375,11 @@ const make = Effect.gen(function* () { const formatFailureDetail = (cause: Cause.Cause): string => { const failReason = cause.reasons.find(Cause.isFailReason); - if (isProviderAdapterRequestError(failReason?.error)) { - return failReason.error.detail; - } - if (isProviderAdapterValidationError(failReason?.error)) { - return failReason.error.issue; + const providerError = isProviderAdapterRequestError(failReason?.error) + ? failReason.error + : undefined; + if (providerError) { + return providerError.detail; } return Cause.pretty(cause); }; @@ -1146,6 +1137,7 @@ const make = Effect.gen(function* () { if (!thread) { return; } + const message = thread.messages.find((entry) => entry.id === event.payload.messageId); if (!message || message.role !== "user") { yield* appendProviderFailureActivity({ @@ -1155,28 +1147,15 @@ const make = Effect.gen(function* () { detail: `User message '${event.payload.messageId}' was not found for turn start request.`, turnId: null, createdAt: event.payload.createdAt, - requestId: event.payload.messageId, }); return; } - const appendTurnStartFailure = (summary: string, detail: string) => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary, - detail, - turnId: null, - createdAt: event.payload.createdAt, - requestId: event.payload.messageId, - }); yield* ensureThreadWorktree(thread); - const isCompactCommand = isCompactCommandMessage(message); - const nonCompactUserMessageCount = thread.messages.filter( - (entry) => entry.role === "user" && !isCompactCommandMessage(entry), - ).length; - if (nonCompactUserMessageCount === 1 && !isCompactCommand) { + const isFirstUserMessageTurn = + thread.messages.filter((entry) => entry.role === "user").length === 1; + if (isFirstUserMessageTurn) { const project = yield* resolveProject(thread.projectId); const generationCwd = resolveThreadWorkspaceCwd({ @@ -1215,7 +1194,16 @@ const make = Effect.gen(function* () { detail, createdAt: event.payload.createdAt, }).pipe( - Effect.flatMap(() => appendTurnStartFailure("Provider turn start failed", detail)), + Effect.flatMap(() => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + }), + ), Effect.asVoid, ); }; @@ -1232,69 +1220,6 @@ const make = Effect.gen(function* () { ), ); - const handleCompactionFailure = (cause: Cause.Cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.void; - } - const detail = formatFailureDetail(cause); - return appendTurnStartFailure("Context compaction failed", detail).pipe(Effect.asVoid); - }; - const recoverCompactionFailure = (cause: Cause.Cause) => - handleCompactionFailure(cause).pipe( - Effect.catchCause((recoveryCause) => - Effect.logWarning("provider command reactor failed to recover compaction failure", { - eventType: event.type, - threadId: event.payload.threadId, - cause: Cause.pretty(recoveryCause), - originalCause: Cause.pretty(cause), - }), - ), - ); - if (isCompactCommand) { - if (nonCompactUserMessageCount === 0) { - return yield* appendTurnStartFailure( - "Context compaction failed", - "Context compaction requires an existing conversation.", - ); - } - const latestThread = yield* resolveThread(event.payload.threadId); - if ( - compactingThreadIds.has(event.payload.threadId) || - latestThread?.session?.status === "starting" || - latestThread?.session?.status === "running" - ) { - yield* appendTurnStartFailure( - "Context compaction failed", - "Context compaction is unavailable while a provider turn is running.", - ); - return; - } - compactingThreadIds.add(event.payload.threadId); - yield* Effect.gen(function* () { - yield* ensureSessionForThread( - event.payload.threadId, - event.payload.createdAt, - event.payload.modelSelection !== undefined - ? { modelSelection: event.payload.modelSelection } - : undefined, - ); - if (event.payload.modelSelection !== undefined) { - threadModelSelections.set(event.payload.threadId, event.payload.modelSelection); - } - yield* providerService.compactThread(event.payload.threadId, event.payload.modelSelection); - }).pipe( - Effect.catchCause(recoverCompactionFailure), - Effect.ensuring(Effect.sync(() => void compactingThreadIds.delete(event.payload.threadId))), - Effect.forkScoped, - ); - return; - } - if (compactingThreadIds.has(event.payload.threadId)) { - return yield* appendTurnStartFailure( - "Provider turn start failed", - "Wait for context compaction to finish before sending another message.", - ); - } const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, messageText: message.text, @@ -1315,7 +1240,7 @@ const make = Effect.gen(function* () { yield* providerService .sendTurn(sendTurnRequest.value) - .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 6a8dbd70e43e..26332f9f8c9c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -105,7 +105,6 @@ function createProviderServiceHarness() { const service: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), - compactThread: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), @@ -3222,8 +3221,6 @@ describe("ProviderRuntimeIngestion", () => { turnId: asTurnId("turn-1"), payload: { state: "compacted", - beforeTokens: 899_000, - afterTokens: 19_000, detail: { source: "provider" }, }, }); @@ -3237,7 +3234,7 @@ describe("ProviderRuntimeIngestion", () => { const activity = thread.activities.find( (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction", ); - expect(activity?.summary).toBe("Compacted context 899K → 19K tokens"); + expect(activity?.summary).toBe("Context compacted"); expect(activity?.tone).toBe("info"); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 6f8d7009c317..a90010f0b6e2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -28,7 +28,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import { formatTokens } from "@t3tools/shared/usageFormat"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; @@ -754,23 +753,15 @@ export function runtimeEventToActivities( return []; } - const beforeTokens = event.payload.beforeTokens; - const afterTokens = event.payload.afterTokens; - const summary = - beforeTokens !== undefined && afterTokens !== undefined - ? `Compacted context ${formatTokens(beforeTokens)} → ${formatTokens(afterTokens)} tokens` - : "Context compacted"; return [ { id: event.eventId, createdAt: event.createdAt, tone: "info", kind: "context-compaction", - summary, + summary: "Context compacted", payload: { state: event.payload.state, - ...(beforeTokens !== undefined ? { beforeTokens } : {}), - ...(afterTokens !== undefined ? { afterTokens } : {}), ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), }, turnId: toTurnId(event.turnId) ?? null, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 916b740ef259..759a16410931 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -729,7 +729,12 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* adapter.compactThread!(session.threadId, modelSelection); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + modelSelection, + }); const promptText = yield* Effect.promise(() => readFirstPromptText(harness.getLastCreateQueryInput()), @@ -2227,12 +2232,6 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - const compactionEvent = runtimeEvents.find( - (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", - ); - assert.ok(compactionEvent?.type === "thread.state.changed"); - assert.equal(compactionEvent.payload.beforeTokens, 200); - assert.equal(compactionEvent.payload.afterTokens, 40); const finalUsageEvent = runtimeEvents.findLast( (event) => event.type === "thread.token-usage.updated", ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f48a7cf50925..07739146b074 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3198,36 +3198,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; - case "compact_boundary": { + case "compact_boundary": if (context.turnState) { context.turnState.latestAssistantUsage = undefined; context.turnState.compactedSinceLatestAssistantUsage = true; } - const compactedUsage = compactBoundaryTokenUsageSnapshot( - message as unknown as Record, - context.lastKnownContextWindow, - context.lastKnownTotalProcessedTokens, + yield* emitThreadTokenUsage( + context, + compactBoundaryTokenUsageSnapshot( + message as unknown as Record, + context.lastKnownContextWindow, + context.lastKnownTotalProcessedTokens, + ), + { + rawMethod: "claude/system/compact_boundary", + rawPayload: message, + }, ); - yield* emitThreadTokenUsage(context, compactedUsage, { - rawMethod: "claude/system/compact_boundary", - rawPayload: message, - }); yield* offerRuntimeEvent({ ...base, type: "thread.state.changed", payload: { state: "compacted", - ...(compactedUsage?.lastUsedTokens !== undefined - ? { beforeTokens: compactedUsage.lastUsedTokens } - : {}), - ...(compactedUsage?.usedTokens !== undefined - ? { afterTokens: compactedUsage.usedTokens } - : {}), detail: message, }, }); return; - } case "hook_started": yield* offerRuntimeEvent({ ...base, @@ -3786,7 +3782,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Same reason as the approvals above: a request nobody can answer any more // must not stay open, or the thread can never be settled. - for (const pending of context.pendingUserInputs.values()) { + for (const pending of [...context.pendingUserInputs.values()]) { yield* pending.cancel; } @@ -4694,16 +4690,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; }); - const compactThread: NonNullable = ( - threadId, - modelSelection, - ) => - sendTurn({ - threadId, - input: "/compact", - ...(modelSelection !== undefined ? { modelSelection } : {}), - }).pipe(Effect.asVoid); - const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, _turnId) { const context = yield* requireSession(threadId); @@ -4818,7 +4804,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, startSession, sendTurn, - compactThread, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ecb8bb888fd8..bf41046f61e8 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -22,7 +22,6 @@ import { import { buildServerProvider, - COMPACT_SLASH_COMMAND, DEFAULT_TIMEOUT_MS, isCommandMissingCause, parseGenericCliVersion, @@ -504,7 +503,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; + const slashCommands = [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, + ...(capabilities?.slashCommands ?? []), + ]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 156e0b239184..f01192f8d707 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -84,8 +84,6 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); - public readonly compactThread = Effect.void; - public readonly interruptTurnImpl = vi.fn( (_turnId?: TurnId): Promise => Promise.resolve(undefined), ); @@ -338,47 +336,6 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); - it.effect("compacts the active Codex thread and emits compacted state", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const threadId = asThreadId("thread-compact"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("codex"), - threadId, - runtimeMode: "full-access", - }); - const runtime = sessionRuntimeFactory.lastRuntime; - NodeAssert.ok(runtime); - const compactedEventFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.type === "thread.state.changed"), - Stream.runHead, - Effect.forkChild, - ); - yield* adapter.compactThread!(threadId); - yield* runtime.emit({ - id: asEventId("evt-compaction-item-completed"), - kind: "notification", - provider: ProviderDriverKind.make("codex"), - createdAt: "2026-01-01T00:00:00.000Z", - method: "item/completed", - threadId, - payload: { - completedAtMs: 1_778_000_000_000, - threadId: "provider-thread-1", - turnId: "provider-compact-turn", - item: { - id: "provider-compact-item", - type: "contextCompaction", - }, - }, - }); - const event = Option.getOrThrow(yield* Fiber.join(compactedEventFiber)); - NodeAssert.ok(event.type === "thread.state.changed"); - NodeAssert.equal(event.payload.state, "compacted"); - yield* adapter.stopSession(threadId); - }), - ); - it.effect("uploads feedback for the active Codex thread", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d924f3b2f476..1aed82a28868 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -11,7 +11,6 @@ import { type CanonicalItemType, type CanonicalRequestType, type CodexSettings, - EventId, ProviderDriverKind, type ProviderEvent, ProviderInstanceId, @@ -1152,18 +1151,7 @@ function mapToRuntimeEvents( ]; } const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed"); - if (!completed || itemType !== "context_compaction") { - return completed ? [completed] : []; - } - return [ - completed, - { - ...runtimeEventBase(event, canonicalThreadId), - eventId: EventId.make(`${event.id}:thread-compacted`), - type: "thread.state.changed", - payload: { state: "compacted" }, - }, - ]; + return completed ? [completed] : []; } if ( @@ -1891,15 +1879,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); - const compactThread: NonNullable = Effect.fn("compactThread")( - function* (threadId) { - const session = yield* requireSession(threadId); - yield* session.runtime.compactThread.pipe( - Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), - ); - }, - ); - const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.readThread), @@ -2035,7 +2014,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }, startSession, sendTurn, - compactThread, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 169b56e785a6..4d7efbe2106b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -30,7 +30,6 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, - COMPACT_SLASH_COMMAND, type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; @@ -649,7 +648,6 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu models: snapshot.models, skills: snapshot.skills, slashCommands: [ - COMPACT_SLASH_COMMAND, { name: "feedback", description: "Send this thread and Codex logs to OpenAI", diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 4b88b7ce01c0..d83489763f5c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -196,7 +196,6 @@ export interface CodexSessionRuntimeShape { readonly sendTurn: ( input: CodexSessionRuntimeSendTurnInput, ) => Effect.Effect; - readonly compactThread: Effect.Effect; readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; readonly readThread: Effect.Effect; readonly rollbackThread: ( @@ -2294,10 +2293,6 @@ export const makeCodexSessionRuntime = ( return { start, getSession: Ref.get(sessionRef), - compactThread: Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - yield* client.request("thread/compact/start", { threadId: providerThreadId }); - }), sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index e6c7853844e0..fee4306c4c5c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -34,7 +34,6 @@ import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, buildServerProvider, - COMPACT_SLASH_COMMAND, collectStreamAsString, isCommandMissingCause, providerModelsFromSettings, @@ -640,7 +639,6 @@ export function buildCursorProviderSnapshot(input: { input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), - slashCommands: [COMPACT_SLASH_COMMAND], probe: { installed: true, version: input.parsed.version, diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 7ecc75a7401f..753dc2f4a431 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -21,7 +21,6 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, - COMPACT_SLASH_COMMAND, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -497,7 +496,6 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func checkedAt, models, skills, - slashCommands: [COMPACT_SLASH_COMMAND], probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 4e2321904f8c..7f327cae8fb3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -79,7 +79,6 @@ const runtimeMock = { messageCalls: [] as Array<{ sessionID: string; messageID: string }>, messageFailures: 0, promptCalls: [] as Array, - summarizeCalls: [] as Array, promptAsyncError: null as Error | null, promptAsyncImplementation: null as (() => Promise) | null, autoPromptEcho: true, @@ -130,7 +129,6 @@ const runtimeMock = { this.state.messageCalls.length = 0; this.state.messageFailures = 0; this.state.promptCalls.length = 0; - this.state.summarizeCalls.length = 0; this.state.promptAsyncError = null; this.state.promptAsyncImplementation = null; this.state.autoPromptEcho = true; @@ -315,10 +313,6 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }); } }, - summarize: async (input: unknown) => { - runtimeMock.state.summarizeCalls.push(input); - return { data: true }; - }, messages: async () => ({ data: runtimeMock.state.messages }), message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { runtimeMock.state.messageCalls.push({ sessionID, messageID }); @@ -955,39 +949,6 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("compacts through the native OpenCode session API", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-compact"); - runtimeMock.state.subscribedEvents.push({ - type: "session.compacted", - properties: { sessionID: "http://127.0.0.1:9999/session" }, - }); - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(3), - Stream.runCollect, - Effect.forkChild, - ); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - yield* adapter.compactThread!( - threadId, - createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), - ); - const summarizeCall = runtimeMock.state.summarizeCalls[0] as Record; - NodeAssert.equal(summarizeCall.modelID, "gpt-5"); - const events = Array.from(yield* Fiber.join(eventsFiber)); - yield* adapter.stopSession(threadId); - const compacted = events.some( - (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", - ); - NodeAssert.equal(compacted, true); - }), - ); it.effect("falls back to a fresh session when the persisted session is gone", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index e7eb14fd0c14..d0b4f0de78ce 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2014,21 +2014,6 @@ export function makeOpenCodeAdapter( } break; } - case "session.compacted": { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - raw: event, - })), - type: "thread.state.changed", - payload: { - state: "compacted", - detail: event, - }, - }); - break; - } case "message.updated": { const promptAdmission = context.promptAdmission; @@ -2999,71 +2984,6 @@ export function makeOpenCodeAdapter( ); }); - const compactThread: NonNullable = Effect.fn( - "compactThread", - )(function* (threadId, requestedModelSelection) { - const context = yield* ensureSessionContext(sessions, threadId); - yield* awaitOpenCodeContextReady(context); - const modelSelection = - requestedModelSelection ?? - (context.session.model - ? { instanceId: boundInstanceId, model: context.session.model } - : undefined); - if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "compactThread", - issue: `OpenCode model selection is bound to instance '${modelSelection.instanceId}', expected '${boundInstanceId}'.`, - }); - } - const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); - if (!parsedModel) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "compactThread", - issue: "OpenCode compaction requires an active 'provider/model' selection.", - }); - } - yield* context.promptSemaphore.withPermit( - Effect.gen(function* () { - if (sessions.get(threadId) !== context || (yield* Ref.get(context.stopped))) { - return yield* Effect.interrupt; - } - if (context.activeTurnId !== undefined) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "compactThread", - issue: "OpenCode cannot compact while a turn is running.", - }); - } - yield* runOpenCodeSdk("session.summarize", (signal) => - context.client.session.summarize( - { - sessionID: context.openCodeSessionId, - ...parsedModel, - auto: false, - }, - { signal }, - ), - ).pipe( - Effect.timeout("10 minutes"), - Effect.catchTags({ - OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), - TimeoutError: (cause) => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.summarize", - detail: "OpenCode session compaction did not complete within 10 minutes.", - cause, - }), - ), - }), - Effect.asVoid, - ); - }), - ); - }); const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); @@ -3348,7 +3268,6 @@ export function makeOpenCodeAdapter( }, startSession, sendTurn, - compactThread, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 3366209d0009..a094fe8601a2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -13,7 +13,6 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, - COMPACT_SLASH_COMMAND, nonEmptyTrimmed, parseGenericCliVersion, providerModelsFromSettings, @@ -496,7 +495,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu checkedAt, models, skills, - slashCommands: [COMPACT_SLASH_COMMAND], probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b573c80282d3..08650758c308 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -48,7 +48,6 @@ import { import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; -import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -385,7 +384,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te shortDescription: "Debug failing GitHub Actions checks", }, ]); - assert.deepStrictEqual(status.slashCommands.slice(1), [ + assert.deepStrictEqual(status.slashCommands, [ { name: "feedback", description: "Send this thread and Codex logs to OpenAI", @@ -2440,7 +2439,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - assert.deepStrictEqual(status.slashCommands.slice(1), [ + assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "review", description: "Review a pull request", @@ -2484,7 +2487,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ - COMPACT_SLASH_COMMAND, + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "ui", description: "Explore and refine UI", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 84157388fcb1..83f22d475b4a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -172,18 +172,6 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { Effect.void, ); - const compactThread = vi.fn((threadId: ThreadId) => - Effect.sync(() => - emit({ - type: "thread.state.changed", - eventId: asEventId("evt-native-compact"), - provider, - createdAt: "2026-01-01T00:00:00.000Z", - threadId, - payload: { state: "compacted" }, - }), - ), - ); const respondToRequest = vi.fn( ( _threadId: ThreadId, @@ -262,7 +250,6 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { }, startSession, sendTurn, - ...(provider === CODEX_DRIVER ? { compactThread } : {}), interruptTurn, respondToRequest, respondToUserInput, @@ -299,7 +286,6 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { updateSession, startSession, sendTurn, - compactThread, interruptTurn, respondToRequest, respondToUserInput, @@ -1048,10 +1034,6 @@ routing.layer("ProviderServiceLive routing", (it) => { }); assert.equal(routing.codex.sendTurn.mock.calls.length, 1); - yield* advanceTestClock(10); - yield* provider.compactThread(session.threadId); - assert.deepEqual(routing.codex.compactThread.mock.calls, [[session.threadId, undefined]]); - yield* provider.interruptTurn({ threadId: session.threadId }); assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.threadId, undefined]]); @@ -1115,40 +1097,6 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("marks a successful fallback compaction as compacted", () => - Effect.gen(function* () { - const provider = yield* ProviderService.ProviderService; - const threadId = asThreadId("thread-compact-cursor"); - yield* provider.startSession(threadId, { - provider: CURSOR_DRIVER, - providerInstanceId: ProviderInstanceId.make("cursor"), - threadId, - runtimeMode: "full-access", - }); - const compactedEventFiber = yield* provider.streamEvents.pipe( - Stream.filter((event) => event.type === "thread.state.changed"), - Stream.runHead, - Effect.forkChild, - ); - const compactFiber = yield* provider.compactThread(threadId).pipe(Effect.forkChild); - yield* advanceTestClock(50); - routing.cursor.emit({ - type: "turn.completed", - eventId: asEventId("evt-cursor-compact-completed"), - provider: CURSOR_DRIVER, - createdAt: "2026-01-01T00:00:01.000Z", - threadId, - turnId: asTurnId(`turn-${threadId}`), - payload: { state: "completed" }, - }); - yield* Fiber.join(compactFiber); - - const compacted = yield* Fiber.join(compactedEventFiber); - assert.equal(compacted._tag, "Some"); - yield* provider.stopSession({ threadId }); - }), - ); - it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7db5e30e1878..a75f2977d4d6 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -10,7 +10,6 @@ * @module ProviderServiceLive */ import { - EventId, ModelSelection, NonNegativeInt, ThreadId, @@ -29,7 +28,6 @@ import { import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -51,7 +49,6 @@ import { providerTurnMetricAttributes, withMetrics, } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError } from "../Errors.ts"; import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; @@ -236,17 +233,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const revokeMcpCredential = options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; const runtimeEventPubSub = yield* PubSub.unbounded(); - const pendingCompactions = new Map< - ThreadId, - { readonly completion: Deferred.Deferred; readonly synthesizeCompactedEvent: boolean } - >(); - const settleCompaction = (threadId: ThreadId, terminal: string) => { - const pending = pendingCompactions.get(threadId); - pendingCompactions.delete(threadId); - return pending - ? Deferred.succeed(pending.completion, terminal).pipe(Effect.as(pending)) - : Effect.succeed(undefined); - }; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); /** * Attach the `t3-code` MCP server to the session that is about to start. @@ -359,58 +345,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, event: ProviderRuntimeEvent, ): Effect.Effect => - Effect.gen(function* () { - const canonicalEvent = yield* Effect.sync(() => - correlateRuntimeEventWithInstance(source, event), - ); - yield* increment(providerRuntimeEventsTotal, { - provider: canonicalEvent.provider, - eventType: canonicalEvent.type, - }); - yield* publishRuntimeEvent(canonicalEvent); - const pendingCompaction = pendingCompactions.get(canonicalEvent.threadId); - if (!pendingCompaction) return; - if ( - !pendingCompaction.synthesizeCompactedEvent && - canonicalEvent.type === "thread.state.changed" && - canonicalEvent.payload.state === "compacted" - ) { - yield* settleCompaction(canonicalEvent.threadId, "completed"); - return; - } - const compactionTerminal = - canonicalEvent.type === "turn.completed" - ? canonicalEvent.payload.state - : canonicalEvent.type === "session.exited" || - canonicalEvent.type === "runtime.error" || - canonicalEvent.type === "turn.aborted" - ? canonicalEvent.type - : null; - const settledCompaction = - compactionTerminal !== null && - (yield* settleCompaction(canonicalEvent.threadId, compactionTerminal)); - if ( - !settledCompaction || - compactionTerminal !== "completed" || - !settledCompaction.synthesizeCompactedEvent - ) { - return; - } - const compactedEvent = { - ...canonicalEvent, - eventId: EventId.make(`${canonicalEvent.eventId}:context-compaction`), - type: "thread.state.changed", - payload: { - state: "compacted", - detail: { source: "provider-native-command" }, - }, - } satisfies ProviderRuntimeEvent; - yield* increment(providerRuntimeEventsTotal, { - provider: compactedEvent.provider, - eventType: compactedEvent.type, - }); - yield* publishRuntimeEvent(compactedEvent); - }); + Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( + Effect.flatMap((canonicalEvent) => + increment(providerRuntimeEventsTotal, { + provider: canonicalEvent.provider, + eventType: canonicalEvent.type, + }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + ), + ); // `subscribedAdapters` is our source-of-truth for "which instance adapters // are currently wired into the runtime event bus". It both tracks the set @@ -912,50 +854,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); - const compactThread: ProviderServiceMethod<"compactThread"> = Effect.fn("compactThread")( - function* (threadId, modelSelection) { - const routed = yield* resolveRoutableSession({ - threadId, - operation: "ProviderService.compactThread", - allowRecovery: true, - }); - yield* Effect.annotateCurrentSpan({ - "provider.operation": "compact-thread", - "provider.kind": routed.adapter.provider, - "provider.thread_id": threadId, - }); - yield* McpSessionRegistry.touchActiveMcpThread(threadId); - const nativeCompaction = routed.adapter.compactThread; - const completion = yield* Deferred.make(); - pendingCompactions.set(threadId, { - completion, - synthesizeCompactedEvent: nativeCompaction === undefined, - }); - const terminal = yield* ( - nativeCompaction - ? nativeCompaction(routed.threadId, modelSelection) - : sendTurn({ - threadId, - input: routed.adapter.provider === "cursor" ? "/compress" : "/compact", - ...(modelSelection !== undefined ? { modelSelection } : {}), - }) - ).pipe( - Effect.andThen(Deferred.await(completion)), - Effect.ensuring(Effect.sync(() => void pendingCompactions.delete(threadId))), - ); - if (terminal !== "completed") { - return yield* new ProviderAdapterRequestError({ - provider: routed.adapter.provider, - method: "turn/start", - detail: `Context compaction ended with ${terminal}.`, - }); - } - yield* analytics.record("provider.thread.compacted", { - provider: routed.adapter.provider, - }); - }, - ); - const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -1351,7 +1249,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { startSession, sendTurn, - compactThread, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 2f17f9ea9fab..692181531aae 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -164,7 +164,6 @@ describe("ProviderSessionReaper", () => { const providerService: ProviderServiceShape = { startSession: () => unsupported(), sendTurn: () => unsupported(), - compactThread: () => unsupported(), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 9b0d50fd08db..dcf8eff4a27d 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -68,11 +68,6 @@ export interface ProviderAdapterShape { input: ProviderSendTurnInput, ) => Effect.Effect; - readonly compactThread?: ( - threadId: ThreadId, - modelSelection?: ProviderSendTurnInput["modelSelection"], - ) => Effect.Effect; - /** * Interrupt an active turn. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index da3895587f68..545641d2e866 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -53,11 +53,6 @@ export interface ProviderServiceShape { input: ProviderSendTurnInput, ) => Effect.Effect; - readonly compactThread: ( - threadId: ThreadId, - modelSelection?: ProviderSendTurnInput["modelSelection"], - ) => Effect.Effect; - /** * Interrupt a running provider turn. */ diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index b372b74b119a..adbe110d9408 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -22,11 +22,6 @@ export const DEFAULT_TIMEOUT_MS = 4_000; // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) export const AUTH_PROBE_TIMEOUT_MS = 10_000; -export const COMPACT_SLASH_COMMAND = { - name: "compact", - description: "Summarize the conversation and reduce context usage", -} satisfies ServerProviderSlashCommand; - export interface CommandResult { readonly stdout: string; readonly stderr: string; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 5cfd8b6b2b64..aa1b1a7f9788 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -51,7 +51,6 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => ({ startSession: () => Effect.die("unused"), sendTurn: () => Effect.die("unused"), - compactThread: () => Effect.die("unused"), interruptTurn: () => Effect.die("unused"), respondToRequest: () => Effect.die("unused"), respondToUserInput: () => Effect.die("unused"), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ad43381c645e..2bd86e0eb24a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -328,7 +328,7 @@ import { import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { - hasAvailableCompactionProvider, + hasAvailableClaudeCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; @@ -2947,23 +2947,13 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; - const activeProviderStatus = useMemo(() => { - if (activeProviderInstanceId) { - return ( - providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null - ); - } - const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); - return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; - }, [activeProviderInstanceId, providerStatuses, selectedProvider]); - const manualCompactionProviderAvailable = useMemo( + const compactionProviderAvailable = useMemo( () => - hasAvailableCompactionProvider({ + hasAvailableClaudeCompactionProvider({ providers: applyProviderInstanceSettings( deriveProviderInstanceEntries(providerStatuses), settings, ), - driverKind: selectedProvider, instanceId: activeProviderInstanceId, lockedInstanceId: lockedProvider ? (activeThread?.session?.providerInstanceId ?? @@ -2977,10 +2967,18 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.session?.providerInstanceId, lockedProvider, providerStatuses, - selectedProvider, settings, ], ); + const activeProviderStatus = useMemo(() => { + if (activeProviderInstanceId) { + return ( + providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null + ); + } + const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); + return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; + }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = useLocalStorage( `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, @@ -5246,11 +5244,12 @@ function ChatViewContent(props: ChatViewProps) { activeThread && activeContextWindow ? `${activeThread.id}:${activeContextWindow.updatedAt}` : null; - const compactThreadUnavailable = + const compactDisabled = !activeThread || !activeProject || !isServerThread || - !manualCompactionProviderAvailable || + selectedProvider !== "claudeAgent" || + !compactionProviderAvailable || isWorking || threadDetailLoading || isPreparingWorktree || @@ -5258,15 +5257,15 @@ function ChatViewContent(props: ChatViewProps) { feedbackUploading || pendingApprovals.length > 0 || pendingUserInputs.length > 0 || - showPlanFollowUpPrompt; - const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; + showPlanFollowUpPrompt || + composerHasUnsentContent; const compactDisabledReason = compactDisabled ? composerHasUnsentContent ? "Send or clear your draft before compacting" : !activeProject ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" + : !compactionProviderAvailable + ? "Enable a Claude provider before compacting" : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { @@ -7650,7 +7649,6 @@ function ChatViewContent(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} - compactThreadUnavailable={compactThreadUnavailable} compactDisabled={compactDisabled} compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e8de1411d3f5..672d4c80c6cd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -163,10 +163,7 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { - providerSupportsManualCompaction, - resolveContextWindowModelDisplayName, -} from "./ContextWindowMeter.logic"; +import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, buildExpandedImagePreview, @@ -694,7 +691,6 @@ export interface ChatComposerProps { // Context window activeContextWindow: ContextWindowSnapshot | null; - compactThreadUnavailable: boolean; compactDisabled: boolean; compactDisabledReason: string | null; @@ -791,7 +787,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProjectDefaultModelSelection, activeThreadModelSelection, activeContextWindow, - compactThreadUnavailable, compactDisabled, compactDisabledReason, resolvedTheme, @@ -1125,7 +1120,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); - const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry); const selectedProviderSkills = selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) : []; @@ -1354,6 +1348,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, ], ); + // ------------------------------------------------------------------ // Derived: composer trigger / menu // ------------------------------------------------------------------ @@ -1365,16 +1360,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); - const compactSlashCommandAvailable = - composerTrigger?.kind === "slash-command" && - !compactThreadUnavailable && - prompt.slice(composerTrigger.rangeEnd).trim() === "" && - composerImages.length + composerFiles.length === 0 && - composerDraft.persistedAttachments.length === 0 && - composerTerminalContexts.length === 0 && - composerElementContexts.length === 0 && - composerPreviewAnnotations.length === 0 && - composerReviewComments.length === 0; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -1443,11 +1428,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) skill.description ?? (skill.scope ? `${skill.scope} skill` : ""), })); - const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( - (item) => item.command.name !== "compact" || compactSlashCommandAvailable, - ); const slashCommandItems = slashCommandItemsForPromptPosition( - [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], + [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems], composerTrigger.rangeStart === 0, ); return searchSlashCommandItems(slashCommandItems, query); @@ -1467,7 +1449,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return []; }, [ - compactSlashCommandAvailable, composerTrigger, planModeUiEnabled, selectedProvider, @@ -2317,6 +2298,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if ( compactDisabled || noProviderAvailable || + composerSendState.hasSendableContent || activePendingApproval !== null || pendingUserInputs.length > 0 || phase === "running" || @@ -2354,6 +2336,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, compactDisabled, composerDraftTarget, + composerSendState.hasSendableContent, isConnecting, isSendBusy, noProviderAvailable, @@ -4419,7 +4402,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactDisabled || noProviderAvailable || isSendBusy || isConnecting } compactDisabledReason={resolvedCompactDisabledReason} - {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} + {...(selectedProvider === "claudeAgent" + ? { onCompactContext: compactThreadContext } + : {})} />
diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index b4c4d2217565..032076c0b74b 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; import { formatContextWindowCompactionMessage, - hasAvailableCompactionProvider, + hasAvailableClaudeCompactionProvider, hasDismissedResumeCompaction, resolveContextWindowModelDisplayName, shouldOfferResumeCompaction, @@ -25,12 +25,12 @@ function claudeProvider(input: { auth: { status: "authenticated" }, checkedAt: "2026-08-24T12:00:00.000Z", models: [], - slashCommands: [{ name: "compact", description: "" }], + slashCommands: [], skills: [], }; } -describe("hasAvailableCompactionProvider", () => { +describe("hasAvailableClaudeCompactionProvider", () => { const originalInstanceId = ProviderInstanceId.make("claude_original"); it("rejects a fallback in a different locked continuation group", () => { @@ -47,9 +47,8 @@ describe("hasAvailableCompactionProvider", () => { ]); expect( - hasAvailableCompactionProvider({ + hasAvailableClaudeCompactionProvider({ providers, - driverKind: ProviderDriverKind.make("claudeAgent"), instanceId: originalInstanceId, lockedInstanceId: originalInstanceId, }), @@ -70,9 +69,8 @@ describe("hasAvailableCompactionProvider", () => { ]); expect( - hasAvailableCompactionProvider({ + hasAvailableClaudeCompactionProvider({ providers, - driverKind: ProviderDriverKind.make("claudeAgent"), instanceId: originalInstanceId, lockedInstanceId: originalInstanceId, }), diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index be3dacb05e92..8e46c16e9a09 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,4 +1,4 @@ -import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, isClaudeResumeCompactionQuestion, @@ -12,33 +12,27 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; -export function providerSupportsManualCompaction( - provider: ProviderInstanceEntry | null | undefined, -): boolean { - return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false; -} - -export function hasAvailableCompactionProvider(input: { +export function hasAvailableClaudeCompactionProvider(input: { readonly providers: ReadonlyArray; - readonly driverKind: ProviderDriverKind; readonly instanceId: ProviderInstanceId | null; readonly lockedInstanceId: ProviderInstanceId | null; }): boolean { - const driverProviders = input.providers.filter( - (provider) => provider.driverKind === input.driverKind, + const claudeProviders = input.providers.filter( + (provider) => provider.driverKind === "claudeAgent", ); const lockedContinuationGroupKey = input.lockedInstanceId - ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId) + ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId) ?.continuationGroupKey : undefined; const compatibleProviders = lockedContinuationGroupKey - ? driverProviders.filter( + ? claudeProviders.filter( (provider) => provider.continuationGroupKey === lockedContinuationGroupKey, ) - : driverProviders; + : claudeProviders; - return providerSupportsManualCompaction( - resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined), + return ( + resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !== + undefined ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e417ef81ccfb..323dac4f1114 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -919,7 +919,7 @@ describe("MessagesTimeline", () => { entry: { id: "work-1", createdAt: "2026-03-17T19:12:28.000Z", - label: "Compacted context 899K → 19K tokens", + label: "Context compacted", tone: "info", }, }, @@ -927,7 +927,7 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain("Compacted context 899K → 19K tokens"); + expect(markup).toContain("Context compacted"); }); it("summarizes changed files in one line", () => { diff --git a/docs/user/composer.md b/docs/user/composer.md index ec3c6384d25c..eea5a658a337 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -196,8 +196,6 @@ such as System, Personal, Project, or App. On mobile, these menus are available on the **New task** screen before you start a thread. They use the skills and commands from the selected environment and provider. -In an existing thread, send `/compact` to reduce context usage. Web and desktop also offer this action from the context meter, and the work log records token counts when the provider reports them. - By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the same `$name` skill token to your message. The original skill name remains searchable. If the provider diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index f4e22150aef3..31ffc6bfc0b6 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -298,8 +298,6 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; const ThreadStateChangedPayload = Schema.Struct({ state: RuntimeThreadState, - beforeTokens: Schema.optional(NonNegativeInt), - afterTokens: Schema.optional(NonNegativeInt), detail: Schema.optional(Schema.Unknown), }); export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; From c742edd46c5b6792ec8647f934a4703f9103aa82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 16:06:31 -0700 Subject: [PATCH 20/29] fix(web): show scroll-to-end as soon as the last message slips under the composer (#9280) Co-authored-by: Claude Code --- apps/web/src/components/ChatView.tsx | 5 ++--- .../components/chat/MessagesTimeline.logic.ts | 18 ++++++++---------- .../components/chat/MessagesTimeline.test.tsx | 17 ++++++++++------- .../src/components/chat/MessagesTimeline.tsx | 11 ++--------- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2bd86e0eb24a..cadb8b3028b3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4408,8 +4408,7 @@ function ChatViewContent(props: ChatViewProps) { // up, and a gesture landing in that window while still pinned would // otherwise break follow with no scroll event left to re-arm it. const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === - false; + resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { @@ -4488,7 +4487,7 @@ function ChatViewContent(props: ChatViewProps) { } removeListeners?.(); }; - }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); + }, [activeThread?.id, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { // Anchored-end space can be remeasured when the turn completes. Once the diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 60ec9a56f480..980a93a59bdf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -145,23 +145,21 @@ export interface TimelineEndState { */ export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; -export function resolveTimelineIsAtEnd( - state: TimelineEndState | undefined, - endInset = 0, -): boolean | undefined { +export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { if (!state) { return undefined; } - if (state.isAtEnd) { - return true; - } const { contentLength, scroll, scrollLength } = state; if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { return state.isAtEnd; } - // contentLength includes the end inset (composer overlay), so subtract it to - // measure the distance to the real content bottom. - return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; + // contentLength includes the composer inset spacer, but the composer hides + // the same amount of viewport, so the inset cancels: plain + // contentLength - scroll - scrollLength is the gap between the last real row + // and the visible edge above the composer. LegendList's own isAtEnd subtracts + // the inset and is true anywhere in the bottom composer-height band, so it is + // only a fallback here, never a short-circuit. + return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } export function shouldPreserveAssistantLineBreaks(text: string): boolean { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 323dac4f1114..1044a956bb66 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -378,14 +378,17 @@ describe("MessagesTimeline", () => { scrollLength: 800, }), ).toBe(false); - // The composer inset is part of contentLength and must not count as - // distance-to-end. + // LegendList's isAtEnd is true anywhere within the composer-height band + // (it subtracts the inset); the last row is still hidden under the + // composer there, so the flag must not short-circuit the geometry. expect( - resolveTimelineIsAtEnd( - { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 }, - 100, - ), - ).toBe(true); + resolveTimelineIsAtEnd({ + isAtEnd: true, + contentLength: 2000, + scroll: 1100, + scrollLength: 800, + }), + ).toBe(false); // Geometry missing (older state shape): fall back to the strict flag. expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 167eb700f849..95f4b35f6655 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -555,7 +555,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); + const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } @@ -581,14 +581,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [ - citationPositioning, - contentInsetEndAdjustment, - listRef, - minimapItems, - minimapStripMap, - onIsAtEndChange, - ]); + }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); From 994bd7373cf3a335c204a617604e690ed4c00cba Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 19:27:53 -0400 Subject: [PATCH 21/29] fix(cursor): honor auto and full access modes (#9283) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/provider/Layers/CursorAdapter.test.ts | 5 +++- .../src/provider/Layers/CursorAdapter.ts | 1 + .../src/provider/acp/CursorAcpSupport.test.ts | 27 +++++++++++++++++++ .../src/provider/acp/CursorAcpSupport.ts | 27 +++++++++++++++++-- docs/user/permission-modes.md | 4 +-- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index cd5cdb7f01aa..edc9cfc7ec27 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -820,6 +820,9 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); assert.isDefined(permissionResponse); + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argvRuns, [["--force", "acp"]]); + yield* adapter.stopSession(threadId); }), ); @@ -1260,7 +1263,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); - assert.deepStrictEqual(argvRuns[0], ["acp"]); + assert.deepStrictEqual(argvRuns[0], ["--force", "acp"]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const setConfigRequests = requests.filter( diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 818fe567b9b3..2dba46d81267 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -537,6 +537,7 @@ export function makeCursorAdapter( ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd, + runtimeMode: input.runtimeMode, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(mcpSession diff --git a/apps/server/src/provider/acp/CursorAcpSupport.test.ts b/apps/server/src/provider/acp/CursorAcpSupport.test.ts index a095fdd679bc..93c1da9c5884 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.test.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.test.ts @@ -74,6 +74,33 @@ describe("buildCursorAcpSpawnInput", () => { cwd: "/tmp/project", }); }); + + it("forces approval in full-access mode", () => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project", undefined, "full-access")).toEqual({ + command: "cursor-agent", + args: ["--force", "acp"], + cwd: "/tmp/project", + }); + }); + + it("uses Cursor auto-review in auto mode", () => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project", undefined, "auto")).toEqual({ + command: "cursor-agent", + args: ["--auto-review", "acp"], + cwd: "/tmp/project", + }); + }); + + it.each(["approval-required", "auto-accept-edits"] as const)( + "does not relax approval in %s mode", + (runtimeMode) => { + expect(buildCursorAcpSpawnInput(undefined, "/tmp/project", undefined, runtimeMode)).toEqual({ + command: "cursor-agent", + args: ["acp"], + cwd: "/tmp/project", + }); + }, + ); }); describe("applyCursorAcpModelSelection", () => { diff --git a/apps/server/src/provider/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts index 30203ad77b1e..e3f741d34260 100644 --- a/apps/server/src/provider/acp/CursorAcpSupport.ts +++ b/apps/server/src/provider/acp/CursorAcpSupport.ts @@ -1,4 +1,8 @@ -import { type CursorSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import { + type CursorSettings, + type ProviderOptionSelection, + type RuntimeMode, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -15,6 +19,17 @@ import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; type CursorAcpRuntimeCursorSettings = Pick; +function cursorAcpPermissionArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "auto": + return ["--auto-review"]; + case "full-access": + return ["--force"]; + default: + return []; + } +} + export interface CursorAcpRuntimeInput extends Omit< AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" @@ -22,6 +37,7 @@ export interface CursorAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; } export interface CursorAcpModelSelectionErrorContext { @@ -34,11 +50,13 @@ export function buildCursorAcpSpawnInput( cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, ): AcpSessionRuntime.AcpSpawnInput { return { command: cursorSettings?.binaryPath || "cursor-agent", args: [ ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + ...cursorAcpPermissionArgs(runtimeMode), "acp", ], cwd, @@ -57,7 +75,12 @@ export const makeCursorAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd, input.environment), + spawn: buildCursorAcpSpawnInput( + input.cursorSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, }).pipe( diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 9bf9c10b20f5..f8448400c524 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -17,8 +17,8 @@ without prompting; commands and anything else still stop for approval. **Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto -permission mode, and providers without an equivalent (such as OpenCode) fall back to asking, like -Supervised. +permission mode, Cursor uses Smart Auto review, and providers without an equivalent (such as +OpenCode) fall back to asking, like Supervised. **Full access**: allow commands and edits without prompts. The default. The agent runs unattended until it finishes or asks a question of its own. From 4ba39a6f408bdee468df2e3ffdf7d5dc08e7b59d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 2 Sep 2026 16:46:37 -0700 Subject: [PATCH 22/29] fix(desktop): detect installed Spectre libs for Windows builds Replace the obsolete Visual Studio Spectre component check with an architecture-specific filesystem check against the latest installed MSVC toolset. This lets current Visual Studio Build Tools installs pass Windows desktop preflight while still failing when the required Spectre libraries are absent. Model: GPT-5 Codex via T3 Code. --- scripts/build-desktop-artifact.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 17c038979d0e..f4bbad9d5407 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1707,23 +1707,21 @@ export const preflightMacDesktopBuild = Effect.fn("preflightMacDesktopBuild")(fu }); function windowsVswherePrerequisiteScript(arch: typeof BuildArch.Type): string { - const components = + const toolComponents = arch === "arm64" - ? [ - "Microsoft.VisualStudio.Component.VC.Tools.ARM64", - "Microsoft.VisualStudio.Component.VC.Tools.ARM64.Spectre", - ] - : [ - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", - ]; + ? ["Microsoft.VisualStudio.Component.VC.Tools.ARM64"] + : ["Microsoft.VisualStudio.Component.VC.Tools.x86.x64"]; + const spectreArch = arch === "arm64" ? "arm64" : "x64"; return [ "$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\\Installer\\vswhere.exe'", "if (!(Test-Path $vswhere)) { exit 1 }", - `$install = & $vswhere -latest -products * -requires ${components.join(" ")} -property installationPath`, + `$install = & $vswhere -latest -products * -requires ${toolComponents.join(" ")} -property installationPath`, "if (!$install) { exit 1 }", "$kitsRoot = Get-ItemPropertyValue 'HKLM:\\SOFTWARE\\Microsoft\\Windows Kits\\Installed Roots' -Name KitsRoot10 -ErrorAction SilentlyContinue", "if (!$kitsRoot -or !(Test-Path (Join-Path $kitsRoot 'Lib'))) { exit 1 }", + "$msvcToolset = Get-ChildItem (Join-Path $install 'VC\\Tools\\MSVC') -Directory | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1", + "if (!$msvcToolset) { exit 1 }", + `if (!(Test-Path (Join-Path $msvcToolset.FullName 'lib\\spectre\\${spectreArch}'))) { exit 1 }`, ].join("; "); } From 443b4ebfe83fcfe64c34b09ecb5a5fffdebb85c7 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 2 Sep 2026 20:00:27 -0400 Subject: [PATCH 23/29] fix(pull-requests): missing features & better behaviour (#9188) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../AzureDevOpsPullRequestCli.test.ts | 2 +- .../pullRequest/AzureDevOpsPullRequestCli.ts | 12 +- .../AzureDevOpsPullRequestProvider.ts | 3 + .../pullRequest/GitHubPullRequestCli.test.ts | 385 +++++++++++++++++- .../src/pullRequest/GitHubPullRequestCli.ts | 370 +++++++++++++++-- .../GitHubPullRequestProvider.test.ts | 286 +++++++++++-- .../pullRequest/GitHubPullRequestProvider.ts | 122 +++++- .../src/pullRequest/GitLabPullRequestCli.ts | 4 + .../src/pullRequest/PullRequestProvider.ts | 4 + .../src/pullRequest/PullRequestService.ts | 9 + .../azureDevOpsPullRequestJson.test.ts | 28 ++ .../pullRequest/azureDevOpsPullRequestJson.ts | 30 ++ .../pullRequest/gitHubPullRequestJson.test.ts | 81 +++- .../src/pullRequest/gitHubPullRequestJson.ts | 126 +++++- .../gitLabMergeRequestJson.test.ts | 22 + .../src/pullRequest/gitLabMergeRequestJson.ts | 9 + .../pullRequest/PullRequestChecksPopover.tsx | 3 +- .../pullRequest/PullRequestDetailPanel.tsx | 224 ++++++++-- .../pullRequest/PullRequestListFilters.tsx | 2 +- .../pullRequest/PullRequestSummaryTab.tsx | 78 +++- .../pullRequest/pullRequestChecks.test.tsx | 33 +- .../pullRequestDetail.logic.test.ts | 54 ++- .../pullRequest/pullRequestDetail.logic.ts | 50 ++- .../pullRequest/pullRequestList.logic.test.ts | 167 ++++++++ .../pullRequest/pullRequestList.logic.ts | 31 +- .../pullRequest/pullRequestListPreferences.ts | 122 ++++++ .../pullRequest/pullRequestPresentation.tsx | 41 +- .../src/components/sidebar/SidebarChrome.tsx | 6 +- apps/web/src/components/ui/badge.tsx | 2 + apps/web/src/components/ui/button.tsx | 2 + apps/web/src/routes/_chat.pull-requests.tsx | 83 ++-- docs/user/source-control.md | 16 +- packages/contracts/src/pullRequest.test.ts | 30 +- packages/contracts/src/pullRequest.ts | 9 + 34 files changed, 2241 insertions(+), 205 deletions(-) create mode 100644 apps/web/src/components/pullRequest/pullRequestListPreferences.ts diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 98e21d75bc58..d893924b3f2a 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -414,7 +414,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ); it.effect.each([ - { action: "enable-auto-merge", expected: ["--auto-complete", "true", "--squash", "false"] }, + { action: "enable-auto-merge", expected: ["--auto-complete", "true"] }, { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, { action: "draft", expected: ["--draft", "true"] }, { action: "ready", expected: ["--draft", "false"] }, diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 96dc4ea1d30e..fe87692e1cc3 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -224,7 +224,13 @@ function actionArgs( // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes // it once its policies pass. The squash choice is stored with it, as it is for a merge now. case "enable-auto-merge": - return ["--auto-complete", "true", "--squash", mergeMethod === "squash" ? "true" : "false"]; + return [ + "--auto-complete", + "true", + ...(mergeMethod === undefined + ? [] + : ["--squash", mergeMethod === "squash" ? "true" : "false"]), + ]; case "disable-auto-merge": return ["--auto-complete", "false"]; case "ready": @@ -238,6 +244,10 @@ function actionArgs( return []; case "reopen": return ["--status", "active"]; + // Never reached: this host does not declare the action, so the service refuses it first. + case "revert": + case "approve-workflows": + throw new Error(`Azure DevOps pull request action ${action} is unsupported`); } } diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 631fee971cc1..0062e58a4c87 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -169,6 +169,9 @@ export const make = Effect.gen(function* () { mergeCapabilities: { merge: true, squash: true, rebase: false }, viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, autoMergeEnabled: pullRequest.autoMergeEnabled, + ...(pullRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: pullRequest.autoMergeMethod }), }), ), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 37f38deb5ce9..3db5c8884f30 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1200,6 +1200,389 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("opens a pull request that reverts a merged pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh GraphQL response. + JSON.stringify({ + data: { repository: { pullRequest: { id: "PR_7" } } }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "revert", + }); + + expect(callAt(0).args).toContain("owner=acme"); + expect(callAt(0).args).toContain("name=web"); + expect(callAt(0).args).toContain("number=7"); + expect(callAt(1).args).toEqual([ + "api", + "graphql", + "--hostname", + "github.com", + "--input", + "-", + ]); + expect(callAt(1).stdin).toContain("revertPullRequest"); + expect(callAt(1).stdin).toContain('"pullRequestId":"PR_7"'); + }), + ); + + it.effect("does not approve action-required runs for a same-repository pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify({ + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: false, + headRepositoryOwner: { login: "acme" }, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }); + + expect(mockedExecute).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("finds and approves every workflow waiting on a maintainer", () => + Effect.gen(function* () { + const detail = output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify({ + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ); + const heads = output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify([ + { + number: 7, + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + }, + ]), + ); + const runs = output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify([ + { databaseId: 10, workflowName: "build", url: "https://example.com/10" }, + { databaseId: 11, workflowName: "test", url: "https://example.com/11" }, + ]), + ); + for (const result of [ + detail, + heads, + runs, + detail, + heads, + runs, + output(""), + detail, + heads, + runs, + output(""), + ]) { + mockedExecute.mockReturnValueOnce(Effect.succeed(result)); + } + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }); + + expect(callAt(1).args).toEqual([ + "pr", + "list", + "--repo", + "github.com/acme/web", + "--state", + "open", + "--head", + "feat/page", + "--limit", + "1001", + "--json", + "number,headRefOid,isCrossRepository,headRepositoryOwner", + ]); + expect(callAt(2).args).toEqual([ + "run", + "list", + "--repo", + "github.com/acme/web", + "--commit", + "abc123", + "--branch", + "feat/page", + "--event", + "pull_request", + "--status", + "action_required", + "--limit", + "1001", + "--json", + "databaseId,workflowName,url", + ]); + expect([callAt(6).args, callAt(10).args]).toEqual([ + [ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/actions/runs/10/approve", + "--silent", + ], + [ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/actions/runs/11/approve", + "--silent", + ], + ]); + expect(mockedExecute).toHaveBeenCalledTimes(11); + }), + ); + + it.effect("refuses a stale workflow approval after the pull request head changes", () => + Effect.gen(function* () { + const detail = { + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }; + for (const value of [ + detail, + [ + { + number: 7, + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + }, + ], + [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], + { ...detail, headRefOid: "def456" }, + ]) { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify(value), + ), + ), + ); + } + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalHeadChangedError", + number: 7, + }); + expect(mockedExecute).toHaveBeenCalledTimes(4); + }), + ); + + it.effect("refuses workflow approval when one head belongs to several pull requests", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify( + [7, 8].map((number) => ({ + number, + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + })), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalRefusedError", + reason: "head-not-unique", + number: 7, + observedCount: 2, + limit: 1_000, + }); + expect(error.detail).toContain("instead of uniquely matching #7"); + expect(mockedExecute).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("refuses workflow approval when GitHub omits the head repository", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify({ + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: null, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "approve-workflows", + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalHeadUnavailableError", + number: 7, + }); + expect(mockedExecute).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("surfaces a workflow run list beyond the safe approval bound", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify([ + { + number: 7, + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + }, + ]), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. + JSON.stringify(Array.from({ length: 1_001 }, (_, id) => ({ databaseId: id + 1 }))), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalRefusedError", + reason: "run-list-truncated", + number: 7, + observedCount: 1_001, + limit: 1_000, + }); + expect(error.detail).toContain("more than 1000 workflow runs"); + expect(mockedExecute).toHaveBeenCalledTimes(2); + }), + ); + it.effect("returns a pull request to draft by undoing ready", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(""))); @@ -2136,7 +2519,7 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(detail.body).toBe("Core body"); expect(activity.author?.login).toBe("octocat"); expect(callAt(0).args.at(-1)).toBe( - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest", + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest", ); expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); }), diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index f11bd264c4b5..c418fd0d37e8 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -34,6 +34,7 @@ import { decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, + decodePullRequestHeadsJson, decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, @@ -56,6 +57,7 @@ import { PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, REMOVE_REACTION_GRAPHQL_MUTATION, + REVERT_PULL_REQUEST_GRAPHQL_MUTATION, gitHubReactionContent, REPOSITORY_ACCESS_JSON_FIELDS, RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, @@ -71,13 +73,16 @@ import { UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, VIEWER_PERMISSIONS_GRAPHQL_QUERY, decodeViewerPermissionsJson, + decodeWorkflowRunApprovalsJson, type GitHubBaseComparison, type GitHubPullRequestDetail, type GitHubPullRequestActivity, + type GitHubPullRequestHead, type GitHubPullRequestListItem, type GitHubPullRequestSearchItem, type GitHubReviewThreadComments, type GitHubRepositoryAccess, + type GitHubWorkflowRunApproval, type GitHubReviewThreadEntry, type GitHubReviewThreadPage, type GitHubViewerAccess, @@ -259,6 +264,69 @@ export class GitHubSubjectScopeError extends Schema.TaggedErrorClass()( + "GitHubWorkflowApprovalRefusedError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + number: Schema.Int, + reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), + observedCount: Schema.Int, + limit: Schema.Int, + }, +) { + get detail(): string { + if (this.reason === "head-list-truncated") { + return `GitHub returned more than ${this.limit} pull requests for this head branch.`; + } + if (this.reason === "head-not-unique") { + return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; + } + return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; + } + + override get message(): string { + return `GitHub CLI refused listWorkflowRunsRequiringApproval: ${this.detail}`; + } +} + +/** GitHub omitted the immutable head identity needed to scope an approval safely. */ +export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErrorClass()( + "GitHubWorkflowApprovalHeadUnavailableError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return `GitHub did not report a complete head revision for #${this.number}.`; + } + + override get message(): string { + return `GitHub CLI refused approve-workflows: ${this.detail}`; + } +} + +/** The pull request moved after its approval candidates were read. */ +export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedErrorClass()( + "GitHubWorkflowApprovalHeadChangedError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + number: Schema.Int, + }, +) { + get detail(): string { + return `The head revision of #${this.number} changed before its workflows could be approved.`; + } + + override get message(): string { + return `GitHub CLI refused approve-workflows: ${this.detail}`; + } +} + export type GitHubPullRequestCliError = | GitHubCli.GitHubCliError | GitHubPullRequestReadError @@ -268,6 +336,9 @@ export type GitHubPullRequestCliError = | GitHubDiffFileContentsUnavailableError | GitHubRepositorySelectorError | GitHubSubjectScopeError + | GitHubWorkflowApprovalRefusedError + | GitHubWorkflowApprovalHeadUnavailableError + | GitHubWorkflowApprovalHeadChangedError | SourceControlRateLimit.SourceControlRateLimitPausedError | GitHubViewerLoginUnavailableError | GitHubPullRequestUpdatedAtUnavailableError; @@ -405,6 +476,17 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; + readonly listWorkflowRunsRequiringApproval: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly headSha: string; + readonly headBranch: string; + readonly headRepositoryOwner: string; + readonly isCrossRepository: true; + }) => Effect.Effect, GitHubPullRequestCliError>; + /** * How far the branch trails its base, and whether this viewer may update it. Its own read * because the comparison needs the head ref the detail answers with — a fork's branch is not @@ -864,6 +946,12 @@ function actionArgs( return ["close"]; case "reopen": return ["reopen"]; + case "revert": + throw new Error("Revert requires a GraphQL mutation"); + // Handled separately because it may approve several workflow runs rather than mutate the + // pull request itself. + case "approve-workflows": + throw new Error("Workflow approval requires run discovery"); } } @@ -1194,6 +1282,158 @@ export const make = Effect.gen(function* () { return { oldContents, newContents }; }); + const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDetail", + cause: decoded.failure, + }), + ); + }), + ); + + const workflowApprovalLimit = 1_000; + const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); + const workflowApprovalReadError = (cwd: string, cause: unknown) => + new GitHubPullRequestReadError({ + command: "gh", + cwd, + operation: "listWorkflowRunsRequiringApproval", + cause, + }); + const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = + (input) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + ...repositoryArgs(input), + "--state", + "open", + "--head", + input.headBranch, + "--limit", + workflowApprovalProbeLimit, + "--json", + "number,headRefOid,isCrossRepository,headRepositoryOwner", + ], + }) + .pipe( + Effect.flatMap( + ( + result, + ): Effect.Effect< + GitHubPullRequestHead, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const decoded = decodePullRequestHeadsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); + } + const exactHeads = decoded.success.filter( + (pullRequest) => + pullRequest.headSha === input.headSha && + pullRequest.isCrossRepository === true && + pullRequest.headRepositoryOwner?.toLowerCase() === + input.headRepositoryOwner.toLowerCase(), + ); + if (decoded.success.length > workflowApprovalLimit) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "head-list-truncated", + observedCount: decoded.success.length, + limit: workflowApprovalLimit, + }), + ); + } + if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "head-not-unique", + observedCount: exactHeads.length, + limit: workflowApprovalLimit, + }), + ); + } + return Effect.succeed(exactHeads[0]); + }, + ), + Effect.flatMap(() => + github.execute({ + cwd: input.cwd, + args: [ + "run", + "list", + ...repositoryArgs(input), + "--commit", + input.headSha, + "--branch", + input.headBranch, + "--event", + "pull_request", + "--status", + "action_required", + "--limit", + workflowApprovalProbeLimit, + "--json", + "databaseId,workflowName,url", + ], + }), + ), + Effect.flatMap( + ( + result, + ): Effect.Effect< + ReadonlyArray, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); + } + return decoded.success.length > workflowApprovalLimit + ? Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "run-list-truncated", + observedCount: decoded.success.length, + limit: workflowApprovalLimit, + }), + ) + : Effect.succeed(decoded.success); + }, + ), + ); + return GitHubPullRequestCli.of({ getViewerLogin: (input) => github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( @@ -1393,34 +1633,8 @@ export const make = Effect.gen(function* () { ), ), - getPullRequestDetail: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestDetail", - cause: decoded.failure, - }), - ); - }), - ), + getPullRequestDetail, + listWorkflowRunsRequiringApproval, getPullRequestBaseComparison: (input) => { const { owner, name } = parseRepositorySelector(input.repository); @@ -1764,6 +1978,106 @@ export const make = Effect.gen(function* () { }, runPullRequestAction: (input) => { + if (input.action === "revert") { + return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, + variables: { pullRequestId }, + }), + ), + ); + } + if (input.action === "approve-workflows") { + const { owner, name } = parseRepositorySelector(input.repository); + return getPullRequestDetail(input).pipe( + Effect.flatMap((detail) => { + if (detail.isCrossRepository !== true) return Effect.void; + if (detail.headSha == null || detail.headRepositoryOwner == null) { + return Effect.fail( + new GitHubWorkflowApprovalHeadUnavailableError({ + command: "gh", + cwd: input.cwd, + number: input.number, + }), + ); + } + const expectedHeadSha = detail.headSha; + const expectedHeadBranch = detail.headBranch; + const expectedHeadRepositoryOwner = detail.headRepositoryOwner; + return listWorkflowRunsRequiringApproval({ + ...input, + headSha: expectedHeadSha, + headBranch: expectedHeadBranch, + headRepositoryOwner: expectedHeadRepositoryOwner, + isCrossRepository: true, + }).pipe( + Effect.flatMap((runs) => + Effect.forEach( + runs, + (run) => + getPullRequestDetail(input).pipe( + Effect.flatMap((current) => { + if (current.headSha == null || current.headRepositoryOwner == null) { + return Effect.fail( + new GitHubWorkflowApprovalHeadUnavailableError({ + command: "gh", + cwd: input.cwd, + number: input.number, + }), + ); + } + if ( + current.isCrossRepository !== true || + current.headSha !== expectedHeadSha || + current.headBranch !== expectedHeadBranch || + current.headRepositoryOwner.toLowerCase() !== + expectedHeadRepositoryOwner.toLowerCase() + ) { + return Effect.fail( + new GitHubWorkflowApprovalHeadChangedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + }), + ); + } + return listWorkflowRunsRequiringApproval({ + ...input, + headSha: current.headSha, + headBranch: current.headBranch, + headRepositoryOwner: current.headRepositoryOwner, + isCrossRepository: true, + }); + }), + Effect.flatMap((currentRuns) => + currentRuns.some((current) => current.id === run.id) + ? github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "--hostname", + input.host, + `repos/${owner}/${name}/actions/runs/${run.id}/approve`, + "--silent", + ], + }) + .pipe(Effect.asVoid) + : Effect.void, + ), + ), + { concurrency: 1, discard: true }, + ), + ), + ); + }), + ); + } const [subcommand, ...flags] = actionArgs( input.action, input.mergeMethod, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index afbeee638230..7e6016288af7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { PullRequestReaction } from "@t3tools/contracts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; @@ -52,6 +53,8 @@ describe("gitHubViewerPermissions", () => { "merge", "enable-auto-merge", "disable-auto-merge", + "revert", + "approve-workflows", "ready", "draft", "close", @@ -108,6 +111,13 @@ describe("gitHubViewerPermissions", () => { verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, }); + expect(detail.workflowApprovalsRequired).toBeUndefined(); + expect(detail.checks).toContainEqual({ + name: "Workflow approval status", + status: "action-required", + description: "GitHub could not determine whether workflows are awaiting approval.", + url: null, + }); }).pipe( Effect.provide( Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ @@ -118,6 +128,7 @@ describe("gitHubViewerPermissions", () => { title: "Pull request 7", url: "https://github.com/acme/web/pull/7", author: null, + isCrossRepository: true, headRepositoryOwner: null, headBranch: "feat/page", baseBranch: "main", @@ -152,39 +163,254 @@ describe("gitHubViewerPermissions", () => { ), ), ); + + it.effect("keeps fork workflows awaiting approval out of the passing state", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.workflowApprovalsRequired).toBe(1); + expect(detail.checks).toEqual([ + { + name: "manual gate", + status: "action-required", + description: null, + url: "https://example.com/manual-gate", + }, + { + name: "build", + status: "success", + description: null, + url: null, + }, + { + name: "contributor tests", + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: "https://github.com/acme/web/actions/runs/123", + }, + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => + Effect.succeed({ + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + isCrossRepository: true, + headRepositoryOwner: "octocat", + headSha: "abc123", + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: "passing", + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [ + { + name: "manual gate", + status: "action-required", + description: null, + url: "https://example.com/manual-gate", + }, + { name: "build", status: "success", description: null, url: null }, + ], + comments: [], + commits: [], + }), + listWorkflowRunsRequiringApproval: () => + Effect.succeed([ + { + id: 123, + name: "contributor tests", + url: "https://github.com/acme/web/actions/runs/123", + }, + ]), + getPullRequestBaseComparison: () => + Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), + getRepositoryAccess: () => + Effect.succeed({ + canWrite: true, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }), + ), + ), + ); }); -describe("getViewerPermissions", () => { - const openDetail = { - authorId: null, - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - author: null, - headRepositoryOwner: "acme", - headBranch: "feat/page", - baseBranch: "main", - state: "open" as const, - isDraft: false, - mergeability: "mergeable" as const, - reviewDecision: null, - additions: 1, - deletions: 1, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - reviewRequestLogins: [], - hasTeamReviewRequest: false, - checksState: null, - labels: [], - body: "", - changedFiles: 1, - mergedAt: null, - closedAt: null, - checks: [], - comments: [], - commits: [], - }; +const openDetail = { + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + isCrossRepository: true, + headRepositoryOwner: "acme", + headSha: "abc123", + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], +}; + +it.effect("does not classify same-repository gates as fork workflow approvals", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(detail.workflowApprovalsRequired).toBe(0); + expect(detail.checks).toEqual([]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), + getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), + listWorkflowRunsRequiringApproval: () => + Effect.die("same-repository pull requests must not probe fork workflow approvals"), + getRepositoryAccess: () => + Effect.succeed({ + canWrite: true, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }), + ), + ), +); + +it.effect("keeps an unsafe workflow approval scope visible as unknown", () => + Effect.gen(function* () { + const provider = yield* make; + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.workflowApprovalsRequired).toBeUndefined(); + expect(detail.checks).toEqual([ + { + name: "Workflow approval status", + status: "action-required", + description: "GitHub could not determine whether workflows are awaiting approval.", + url: null, + }, + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed(openDetail), + getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), + listWorkflowRunsRequiringApproval: () => + Effect.fail( + new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: "/w", + number: 7, + reason: "head-not-unique", + observedCount: 2, + limit: 1_000, + }), + ), + getRepositoryAccess: () => + Effect.succeed({ + canWrite: true, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }), + ), + ), +); + +it.effect("propagates workflow discovery rate limits", () => + Effect.gen(function* () { + const provider = yield* make; + const error = yield* provider + .getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe(Effect.flip); + + expect(error.operation).toBe("getChangeRequest"); + expect(error.reason).toBe("rate-limited"); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed(openDetail), + getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), + listWorkflowRunsRequiringApproval: () => + Effect.fail( + new GitHubCli.GitHubCliRateLimitError({ + command: "gh", + cwd: "/w", + cause: new Error("rate limited"), + }), + ), + getRepositoryAccess: () => + Effect.succeed({ + canWrite: true, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }), + ), + ), +); + +describe("getViewerPermissions", () => { const layerWithComparison = ( comparison: Effect.Effect<{ readonly behindBy: number | null; diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index ff8f31c818dd..5288040de25f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; import type { PullRequestActor, PullRequestCapabilities, + PullRequestCheck, PullRequestReaction, PullRequestViewerPermissions, } from "@t3tools/contracts"; @@ -17,7 +18,7 @@ import { type ProviderChangeRequestDetail, type PullRequestProviderApi, } from "./PullRequestProvider.ts"; -import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts"; +import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, @@ -31,6 +32,8 @@ const CAPABILITIES: PullRequestCapabilities = { "update-branch", "enable-auto-merge", "disable-auto-merge", + "revert", + "approve-workflows", ], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], @@ -68,7 +71,15 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest actions: [ // Arming a merge and taking the arming back are the merge, deferred: whoever may not // merge here may not leave an instruction to merge later either. - ...(access.canWrite ? (["merge", "enable-auto-merge", "disable-auto-merge"] as const) : []), + ...(access.canWrite + ? ([ + "merge", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows", + ] as const) + : []), ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), // Whether this viewer may update the branch is GitHub's own answer, read with the // comparison; without it the action is offered to nobody rather than to everybody. @@ -117,6 +128,43 @@ function withAvatar( return avatarUrl === null ? actor : { ...actor, avatarUrl }; } +function withWorkflowApprovals( + checks: ReadonlyArray, + runs: ReadonlyArray, + unavailable: boolean, +): ReadonlyArray { + const representedRunIds = new Set(); + for (const check of checks) { + if (check.status !== "action-required" || check.url === null) continue; + const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; + if (id !== undefined) representedRunIds.add(Number(id)); + } + const approvalChecks = runs + .filter((run) => !representedRunIds.has(run.id)) + .map( + (run): PullRequestCheck => ({ + name: run.name, + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: run.url, + }), + ); + return [ + ...checks, + ...approvalChecks, + ...(unavailable + ? [ + { + name: "Workflow approval status", + status: "action-required" as const, + description: "GitHub could not determine whether workflows are awaiting approval.", + url: null, + }, + ] + : []), + ]; +} + /** * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which * names no page, and a guessed URL that 404s is worse than the initials it would replace. @@ -250,20 +298,54 @@ export const make = Effect.gen(function* () { [ cli.getPullRequestDetail(input).pipe( Effect.flatMap((pullRequest) => - // Only an open pull request can be behind anything worth saying so about, and only - // one whose head repository is known can be compared at all. A comparison that - // fails is left unknown: the banner is an offer, never a blocker. - pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null - ? Effect.succeed({ pullRequest, comparison: null }) - : cli - .getPullRequestBaseComparison({ - ...input, - headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, - }) - .pipe( - Effect.map((comparison) => ({ pullRequest, comparison })), - Effect.orElseSucceed(() => ({ pullRequest, comparison: null })), - ), + Effect.all({ + // Only an open pull request can be behind anything worth saying so about, and + // only one whose head repository is known can be compared at all. + comparison: + pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null + ? Effect.succeed(null) + : cli + .getPullRequestBaseComparison({ + ...input, + headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, + }) + .pipe(Effect.orElseSucceed(() => null)), + // GitHub omits a fork workflow that has not been approved from the normal check + // rollup. Read the action-required runs by head revision so "all passed" cannot + // be shown while a whole workflow is still waiting to start. + workflowApprovals: + pullRequest.state !== "open" || pullRequest.isCrossRepository !== true + ? Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: false, + }) + : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null + ? Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: true, + }) + : cli + .listWorkflowRunsRequiringApproval({ + ...input, + headSha: pullRequest.headSha, + headBranch: pullRequest.headBranch, + headRepositoryOwner: pullRequest.headRepositoryOwner, + isCrossRepository: true, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + error._tag === "GitHubCliRateLimitError" || + error._tag === "SourceControlRateLimitPausedError" + ? Effect.fail(error) + : Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: true, + }), + onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), + }), + ), + }).pipe(Effect.map((extra) => ({ pullRequest, ...extra }))), ), ), getRepositoryAccess({ @@ -281,6 +363,14 @@ export const make = Effect.gen(function* () { Effect.map( ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ ...detail.pullRequest, + checks: withWorkflowApprovals( + detail.pullRequest.checks, + detail.workflowApprovals.runs, + detail.workflowApprovals.unavailable, + ), + ...(detail.workflowApprovals.unavailable + ? {} + : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ login, name: null, diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 9f968dddbbc8..f291cbd89c7f 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -504,6 +504,10 @@ function actionArgs( return ["rebase"]; case "reopen": return ["reopen"]; + // Never reached: this host does not declare the action, so the service refuses it first. + case "revert": + case "approve-workflows": + throw new Error(`GitLab merge request action ${action} is unsupported`); } } diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 3235538287b1..155bf64109ca 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -177,6 +177,10 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { readonly behindBy?: number; /** Absent from a host that does not report whether it is armed to merge this on its own. */ readonly autoMergeEnabled?: boolean; + /** The strategy stored with an armed auto-merge, where the host reports it. */ + readonly autoMergeMethod?: PullRequestMergeMethod; + /** Workflow runs on this head commit that still need a maintainer's approval. */ + readonly workflowApprovalsRequired?: number; } /** The conversation-shaped half of a detail, loaded after the core can already render. */ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 29496bff64d1..ffa96af12f08 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -202,6 +202,9 @@ const ACTION_ACCESS_REFUSALS: Record = { "You need write access on this repository to have it merged for you once it is ready.", "disable-auto-merge": "You need write access on this repository to stop it being merged for you once it is ready.", + revert: "You need write access on this repository to open a revert pull request.", + "approve-workflows": + "You need write access on this repository to approve workflows from a fork pull request.", }; /** @@ -1290,6 +1293,12 @@ export const make = Effect.gen(function* () { ...(changeRequest.autoMergeEnabled === undefined ? {} : { autoMergeEnabled: changeRequest.autoMergeEnabled }), + ...(changeRequest.autoMergeMethod === undefined + ? {} + : { autoMergeMethod: changeRequest.autoMergeMethod }), + ...(changeRequest.workflowApprovalsRequired === undefined + ? {} + : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), }), ), ), diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index a975c89f858c..6f55eb937bc9 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -159,6 +159,34 @@ describe("decodePullRequestJson", () => { ); }); + it("keeps the strategy stored with auto-complete", () => { + const armed = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + autoCompleteSetBy: { displayName: "Bilal Hassan" }, + completionOptions: { mergeStrategy: "squash" }, + }), + ), + ), + ); + + expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); + + const unspecified = expectSuccess( + decodePullRequestJson( + asJson( + pullRequest({ + autoCompleteSetBy: { displayName: "Bilal Hassan" }, + completionOptions: { squashMerge: false }, + }), + ), + ), + ); + expect(unspecified?.autoMergeEnabled).toBe(true); + expect(unspecified?.autoMergeMethod).toBeUndefined(); + }); + it("works out where the conversation lives from what Azure returned", () => { const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index 39ca4a551d27..55d9b544ab3d 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import type { PullRequestActor, PullRequestComment, + PullRequestMergeMethod, PullRequestMergeability, PullRequestState, } from "@t3tools/contracts"; @@ -41,6 +42,14 @@ const RawPullRequestSchema = Schema.Struct({ * entirely once nobody has. So its presence is the answer, and there is no third state. */ autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), + completionOptions: Schema.optional( + Schema.NullOr( + Schema.Struct({ + mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)), + squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)), + }), + ), + ), mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), @@ -132,6 +141,8 @@ export interface AzureDevOpsPullRequest { readonly threadsUrl: string | null; /** Whether Azure is set to complete this on its own once its policies pass. */ readonly autoMergeEnabled: boolean; + /** The completion strategy Azure stored with auto-complete, where it reported one. */ + readonly autoMergeMethod?: PullRequestMergeMethod; } function trimmed(value: string | null | undefined): string | null { @@ -188,6 +199,23 @@ function toThreadsUrl(raw: Schema.Schema.Type): str return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`; } +function toAutoMergeMethod( + raw: Schema.Schema.Type, +): PullRequestMergeMethod | undefined { + if (raw.autoCompleteSetBy == null) return undefined; + switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { + case "squash": + return "squash"; + case "rebase": + case "rebasemerge": + return "rebase"; + case "nofastforward": + return "merge"; + default: + return raw.completionOptions?.squashMerge === true ? "squash" : undefined; + } +} + /** * Null when Azure said too little to place the pull request: a row with no browser url and no * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract @@ -196,6 +224,7 @@ function toThreadsUrl(raw: Schema.Schema.Type): str function toPullRequest( raw: Schema.Schema.Type, ): AzureDevOpsPullRequest | null { + const autoMergeMethod = toAutoMergeMethod(raw); const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { const actor = toActor(reviewer); return actor === null ? [] : [actor]; @@ -232,6 +261,7 @@ function toPullRequest( reviewers, threadsUrl: toThreadsUrl(raw), autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, + ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), }; } diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f372ac3000a0..000dbada2045 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -16,6 +16,7 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, decodeViewerPermissionsJson, + decodeWorkflowRunApprovalsJson, reviewThreadConversation, REVIEW_THREADS_GRAPHQL_QUERY, } from "./gitHubPullRequestJson.ts"; @@ -223,18 +224,56 @@ describe("pull request detail decoding", () => { ]); }); - it("reads an auto-merge request as armed, its null as off and its absence as neither", () => { + it("keeps a workflow waiting for approval out of the passing state", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS" }, + { + __typename: "CheckRun", + name: "contributor tests", + status: "COMPLETED", + conclusion: "ACTION_REQUIRED", + }, + ], + }), + ), + ); + + expect(detail.checks.map((check) => check.status)).toEqual(["success", "action-required"]); + expect(detail.checksState).toBe("pending"); + }); + + it("decodes workflow runs that can be approved", () => { + expect( + expectSuccess( + decodeWorkflowRunApprovalsJson( + JSON.stringify([ + { databaseId: 10, workflowName: "contributor tests", url: "https://example.com/10" }, + { databaseId: 11, workflowName: null, url: null }, + ]), + ), + ), + ).toEqual([ + { id: 10, name: "contributor tests", url: "https://example.com/10" }, + { id: 11, name: "Workflow run 11", url: null }, + ]); + }); + + it("reads an auto-merge request and strategy, its null as off and its absence as neither", () => { const raw = JSON.parse(detailJson) as Record; const armed = (entry: Record) => - expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))) - .autoMergeEnabled; + expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))); expect( armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }), - ).toBe(true); - expect(armed({ autoMergeRequest: null })).toBe(false); + ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); + expect(armed({ autoMergeRequest: null }).autoMergeEnabled).toBe(false); // `gh` not answering for the field at all is not GitHub saying the merge is unarmed. - expect(armed({})).toBeUndefined(); + expect(armed({}).autoMergeEnabled).toBeUndefined(); }); it("shows a re-running check once, as the run that is happening now", () => { @@ -419,6 +458,36 @@ describe("review thread decoding", () => { ]); }); + it("omits misleading line counts from merge commits", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + commits: { + nodes: [ + { + commit: { + oid: "merge123", + additions: 36_858, + deletions: 12_928, + parents: { totalCount: 2 }, + }, + }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...result.commitStats]).toEqual([]); + }); + it("decodes the newest commits off the same connection, oldest to newest", () => { const result = expectSuccess( decodeReviewThreadsJson( diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6ec17ea111b3..9cc21b429390 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -11,6 +11,7 @@ import type { PullRequestCommit, PullRequestLabel, PullRequestMergeCapabilities, + PullRequestMergeMethod, PullRequestOmittedFileStat, PullRequestMergeability, PullRequestReaction, @@ -359,17 +360,32 @@ const RawCommitSchema = Schema.Struct({ const RawDetailSchema = Schema.Struct({ ...RawListItemSchema.fields, + /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ + isCrossRepository: Schema.optional(Schema.Boolean), /** Names the fork a pull request came from, which is what qualifies its head ref. */ headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + /** The exact head revision, used to find workflow runs that GitHub has not started yet. */ + headRefOid: Schema.optional(Schema.NullOr(Schema.String)), body: Schema.optional(Schema.String), changedFiles: Schema.optional(Schema.Int), closedAt: Schema.optional(Schema.NullOr(Schema.String)), - /** - * The standing instruction to merge once GitHub's own requirements are met, which is an object - * describing who armed it and how, and a JSON null where nobody has. Nothing inside it is read: - * the question the page asks is whether one exists. - */ - autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Unknown)), + /** The standing instruction and strategy GitHub will use once its requirements are met. */ + autoMergeRequest: Schema.optional( + Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const RawWorkflowRunApprovalSchema = Schema.Struct({ + databaseId: Schema.Int, + workflowName: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPullRequestHeadSchema = Schema.Struct({ + number: Schema.Int, + headRefOid: Schema.String, + isCrossRepository: Schema.optional(Schema.Boolean), + headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), }); const RawActivitySchema = Schema.Struct({ @@ -508,6 +524,9 @@ const RawReviewThreadsSchema = Schema.Struct({ committedDate: Schema.optional(Schema.NullOr(Schema.String)), additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), + parents: Schema.optional( + Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), + ), authors: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -599,7 +618,7 @@ export function decodeActorAvatarsJson( export const PULL_REQUEST_LIST_JSON_FIELDS = "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; -export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`; +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ @@ -731,6 +750,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin committedDate additions deletions + parents(first: 1) { totalCount } authors(first: 3) { nodes { name avatarUrl user { login } } } } } @@ -879,6 +899,13 @@ export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID } }`; +/** Creates a new pull request that reverses a merged pull request. */ +export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + revertPullRequest(input: { pullRequestId: $pullRequestId }) { + revertPullRequest { id } + } +}`; + /** * The two comment mutations name their comment differently. The variable is spelled the same in * both, so a rewrite sends one set of variables whichever kind of remark it is. @@ -1016,8 +1043,11 @@ export interface GitHubPullRequestListItem { } export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + /** True only when GitHub says the head belongs to another repository. */ + readonly isCrossRepository?: boolean; /** The owner of the head branch's repository; null where `gh` did not say. */ readonly headRepositoryOwner: string | null; + readonly headSha?: string | null; readonly body: string; readonly changedFiles: number; readonly mergedAt: string | null; @@ -1025,6 +1055,21 @@ export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { readonly checks: ReadonlyArray; /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ readonly autoMergeEnabled?: boolean; + /** Absent where auto-merge is off or GitHub did not report the stored strategy. */ + readonly autoMergeMethod?: PullRequestMergeMethod; +} + +export interface GitHubWorkflowRunApproval { + readonly id: number; + readonly name: string; + readonly url: string | null; +} + +export interface GitHubPullRequestHead { + readonly number: number; + readonly headSha: string; + readonly isCrossRepository?: boolean; + readonly headRepositoryOwner: string | null; } export interface GitHubPullRequestActivity { @@ -1114,6 +1159,19 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili } } +function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined { + switch (value?.trim().toUpperCase()) { + case "MERGE": + return "merge"; + case "SQUASH": + return "squash"; + case "REBASE": + return "rebase"; + default: + return undefined; + } +} + function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { switch (value?.trim().toUpperCase()) { case "APPROVED": @@ -1169,12 +1227,12 @@ function toCheckStatus(raw: Schema.Schema.Type): PullRequ switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { case "SUCCESS": return "success"; + case "ACTION_REQUIRED": + return "action-required"; case "FAILURE": case "ERROR": case "TIMED_OUT": case "STARTUP_FAILURE": - // A completed check asking for manual intervention is blocking, not neutral. - case "ACTION_REQUIRED": return "failure"; case "CANCELLED": return "cancelled"; @@ -1253,7 +1311,7 @@ function rollupChecksState( ]; if (statuses.length === 0) return null; if (statuses.includes("failure")) return "failing"; - if (statuses.includes("pending")) return "pending"; + if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; return statuses.includes("success") ? "passing" : null; } @@ -1361,9 +1419,14 @@ function toListItem(raw: Schema.Schema.Type): GitHubPu } function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { + const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod); return { ...toListItem(raw), + ...(typeof raw.isCrossRepository === "boolean" + ? { isCrossRepository: raw.isCrossRepository } + : {}), headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), + headSha: trimmed(raw.headRefOid), body: raw.body ?? "", changedFiles: raw.changedFiles ?? 0, mergedAt: trimmed(raw.mergedAt), @@ -1374,6 +1437,7 @@ function toDetail(raw: Schema.Schema.Type): GitHubPullRe ...(raw.autoMergeRequest === undefined ? {} : { autoMergeEnabled: raw.autoMergeRequest !== null }), + ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), }; } @@ -1391,6 +1455,8 @@ const decodeSearch = decodeJsonResult(RawSearchSchema); const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); const decodeStats = decodeJsonResult(RawStatsSchema); const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema)); +const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); const decodeActivity = decodeJsonResult(RawActivitySchema); const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema); @@ -1551,6 +1617,37 @@ export function decodePullRequestDetailJson( : Result.fail(decoded.failure); } +export function decodeWorkflowRunApprovalsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeWorkflowRunApprovals(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + return Result.succeed( + decoded.success.map((run) => ({ + id: run.databaseId, + name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`, + url: trimmed(run.url), + })), + ); +} + +export function decodePullRequestHeadsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePullRequestHeads(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + return Result.succeed( + decoded.success.map((pullRequest) => ({ + number: pullRequest.number, + headSha: pullRequest.headRefOid, + ...(typeof pullRequest.isCrossRepository === "boolean" + ? { isCrossRepository: pullRequest.isCrossRepository } + : {}), + headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), + })), + ); +} + export function decodePullRequestActivityJson( raw: string, ): Result.Result { @@ -1788,7 +1885,14 @@ export function decodeReviewThreadsJson( const commit = node.commit; const oid = trimmed(commit.oid); if (oid === null) continue; - if (commit.additions !== undefined && commit.deletions !== undefined) { + // GitHub measures a merge commit against its first parent, so merging the base into the head + // reports every upstream change as if it belonged to the pull request. There is no useful + // per-commit stat to show for that integration commit without another comparison request. + if ( + (commit.parents?.totalCount ?? 1) <= 1 && + commit.additions !== undefined && + commit.deletions !== undefined + ) { commitStats.set(oid, { additions: Math.max(0, commit.additions), deletions: Math.max(0, commit.deletions), diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index 9221c1ab8e04..4438aa0c521a 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -192,6 +192,28 @@ describe("decodeMergeRequestDetailJson", () => { expect(armed({})).toBeUndefined(); }); + it("keeps the squash choice stored with auto-merge", () => { + expect( + expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ auto_merge_enabled: true, squash_on_merge: true }), + ), + ), + ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); + + expect( + expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ + auto_merge_enabled: true, + squash: true, + squash_on_merge: false, + }), + ), + ).autoMergeMethod, + ).toBeUndefined(); + }); + it("keeps a divergence GitLab did not count apart from a divergence of none", () => { const behind = (entry: Record) => expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index 9f4bd96bae08..4b3c797136f8 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -11,6 +11,7 @@ import type { PullRequestLabel, PullRequestMergeability, PullRequestMergeCapabilities, + PullRequestMergeMethod, PullRequestReaction, PullRequestReactionContent, PullRequestReviewThread, @@ -80,6 +81,9 @@ const RawMergeRequestSchema = Schema.Struct({ */ merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** The merge request's stored squash choice, including project-policy overrides. */ + squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), + squash: Schema.optional(Schema.NullOr(Schema.Boolean)), /** * How far the target branch has moved on since this one left it, which is the same number * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab @@ -228,6 +232,8 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { readonly reviewerIds: ReadonlyArray; /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ readonly autoMergeEnabled?: boolean; + /** GitLab only exposes the stored strategy separately when that strategy is squash. */ + readonly autoMergeMethod?: PullRequestMergeMethod; /** * Absent where GitLab did not count, which is not the same as a branch that has nothing behind * it: an install too old to answer must not be read as saying the branch is current. @@ -379,6 +385,9 @@ function toDetail(raw: Schema.Schema.Type): GitLab reviewer.id === undefined ? [] : [reviewer.id], ), ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), + ...(autoMerge === true && raw.squash_on_merge === true + ? { autoMergeMethod: "squash" as const } + : {}), ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), }; } diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx index 8070af60c040..300d3e3062e4 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -65,7 +65,7 @@ function ChecksBody({ checks }: { checks: ReadonlyArray }) { {check.description ?? check.name} - {pullRequestCheckStatusLabel(check.status)} + {pullRequestCheckStatusLabel(check)} {check.url === null ? null : ( + ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} - {autoMergeArmed ? ( + {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( - - Auto-merge + + + {armedAutoMergeLabel} } /> @@ -1369,6 +1446,29 @@ export function PullRequestDetailPanel({ + ) : primaryAction === "enable-auto-merge" ? ( + + ) : primaryAction === "auto-merge-armed" ? ( + + + + {armedAutoMergeLabel} + + } + /> + + The host will merge this on its own once its requirements are met + + ) : primaryAction === "merge" ? ( + ) : (primaryAction === "merged" || primaryAction === "closed") && + statePresentation !== null ? ( + + + {statePresentation.label} + ) : null} ) : null} - {/* The same merge, left with the host to carry out once the things it - waits on are done. It is offered beside the merge rather than instead - of it, because the reader who can wait and the reader who cannot are - the same person on different days — and a conflicting branch is neither, - since nothing the host waits for will clear it. */} + {showsMergeNow ? ( + setConfirmation({ open: true, action: "merge" })} + > + + Merge now + + ) : null} + {/* The same merge, left with the host to carry out once its requirements + pass. A conflicting branch cannot be armed because nothing the host + waits for will clear the conflict. */} {autoMergeArmed && can("disable-auto-merge") ? ( Disable auto-merge - ) : !autoMergeArmed && - !detail.isDraft && - !conflicting && - can("enable-auto-merge") && - allowedMergeMethods.length > 0 ? ( + ) : showsAutoMerge ? ( @@ -1485,7 +1594,9 @@ export function PullRequestDetailPanel({ {/* Only below the draft control. A host with no draft of its own, or a draft whose control is already the header button, would leave this against the separator that opened the group. */} - {showsDraftToggle ? : null} + {showsDraftToggle || showsMergeNow || showsAutoMerge ? ( + + ) : null} @@ -1507,7 +1618,7 @@ export function PullRequestDetailPanel({ ) : null} {pullRequestActionMenuHasGroup( showsDraftToggle, - showsAutoMerge, + showsAutoMerge || showsMergeNow, showsMergeMethods, ) ? ( @@ -1542,6 +1653,17 @@ export function PullRequestDetailPanel({ Reopen pull request + ) : detail.state === "merged" && can("revert") ? ( + <> + + setConfirmation({ open: true, action: "revert" })} + > + + Revert changes + + ) : null} @@ -1987,6 +2109,8 @@ export function PullRequestDetailPanel({ fixFindingLabel={handoffLabels.fixFinding} fixCheckLabel={handoffLabels.fixCheck} onFixFinding={startFixFinding} + actionPending={actionPending} + onCommentAction={performCommentAction} onRefresh={refreshDetail} />
@@ -2049,7 +2173,11 @@ export function PullRequestDetailPanel({ ? "Merge pull request?" : confirmAction === "enable-auto-merge" ? "Enable auto-merge?" - : "Close pull request?"} + : confirmAction === "revert" + ? "Revert these changes?" + : confirmAction === "approve-workflows" + ? "Approve workflows to run?" + : "Close pull request?"} {confirmAction === "merge" @@ -2059,7 +2187,11 @@ export function PullRequestDetailPanel({ // may be immediately — there is no telling from here whether anything is // still outstanding. `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.` - : `This closes #${reference.number} without merging it.`} + : confirmAction === "revert" + ? `This opens a new pull request that reverses the changes merged by #${reference.number}.` + : confirmAction === "approve-workflows" + ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.` + : `This closes #${reference.number} without merging it.`} @@ -2076,6 +2208,8 @@ export function PullRequestDetailPanel({ if (action === "merge") void perform("merge", selectedMergeMethod); if (action === "enable-auto-merge") void perform("enable-auto-merge", selectedMergeMethod); + if (action === "revert") void perform("revert"); + if (action === "approve-workflows") void perform("approve-workflows"); if (action === "close") void perform("close"); }} > @@ -2083,7 +2217,11 @@ export function PullRequestDetailPanel({ ? selectedMergeMethodLabel : confirmAction === "enable-auto-merge" ? "Enable auto-merge" - : "Close"} + : confirmAction === "revert" + ? "Create revert PR" + : confirmAction === "approve-workflows" + ? "Approve and run" + : "Close"} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 9c3bfbab0c19..d550decff894 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -524,7 +524,7 @@ export function PullRequestFiltersMenu({ Filters {filterCount > 0 ? ( - + {filterCount} ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 7d31ec6e4bab..5a3144dadce9 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -9,9 +9,11 @@ import { ArrowDownUpIcon, ChevronDownIcon, ChevronRightIcon, + GitPullRequestClosedIcon, HammerIcon, MessageSquareIcon, PencilIcon, + RotateCcwIcon, SendIcon, TagIcon, UsersIcon, @@ -308,20 +310,43 @@ function Section({ function CommentComposer({ environmentId, detail, + actionPending, + onCommentAction, onCommented, }: { environmentId: EnvironmentId; detail: PullRequestDetailView; + actionPending: boolean; + onCommentAction: ( + body: string, + action: "close" | "reopen", + ) => Promise<{ readonly commentPosted: boolean }>; onCommented: () => void; }) { const [body, setBody] = useState(""); - const [posting, setPosting] = useState(false); + const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); + const followUpAction = + detail.state === "open" && + detail.capabilities.actions.includes("close") && + detail.viewerPermissions.actions.includes("close") + ? ("close" as const) + : detail.state === "closed" && + detail.capabilities.actions.includes("reopen") && + detail.viewerPermissions.actions.includes("reopen") + ? ("reopen" as const) + : null; - const submit = async () => { + const submit = async (action: "comment" | "close" | "reopen") => { const trimmed = body.trim(); - if (trimmed.length === 0 || posting) return; - setPosting(true); + if (trimmed.length === 0 || submitting !== null || actionPending) return; + setSubmitting(action); + if (action !== "comment") { + const result = await onCommentAction(trimmed, action); + if (result.commentPosted) setBody(""); + setSubmitting(null); + return; + } const result = await postComment({ environmentId, input: { @@ -331,12 +356,13 @@ function CommentComposer({ body: trimmed, }, }); - setPosting(false); if (result._tag === "Failure") { + setSubmitting(null); toastManager.add({ type: "error", title: "Could not post the comment" }); return; } setBody(""); + setSubmitting(null); onCommented(); }; @@ -345,22 +371,43 @@ function CommentComposer({