From 33cb911ceb89d55b812f486d4e001d0f86d77680 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 14:49:08 -0700 Subject: [PATCH 01/10] fix(mobile): stop iOS autocorrect from rewriting search queries (#12949) Co-authored-by: Claude Fable 5 --- patches/react-native-screens@4.26.2.patch | 11 ++++++++++- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/patches/react-native-screens@4.26.2.patch b/patches/react-native-screens@4.26.2.patch index ee017ce1993f..9212cfc7b0ff 100644 --- a/patches/react-native-screens@4.26.2.patch +++ b/patches/react-native-screens@4.26.2.patch @@ -294,7 +294,7 @@ index 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab6 break; } case RNSScreenStackHeaderSubviewTypeCenter: -@@ -648,10 +693,470 @@ + (void)updateViewController:(UIViewController *)vc +@@ -648,10 +693,479 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -586,6 +586,15 @@ index 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab6 + ]]; + } + ++ // A bare UISearchTextField keeps UITextField defaults (sentence ++ // capitalization, autocorrect), unlike UISearchBar. Search queries are ++ // identifiers, so match the autoCapitalize "none" the JS fallbacks use. ++ if (resolvedSearchTextField != nil) { ++ resolvedSearchTextField.autocapitalizationType = UITextAutocapitalizationTypeNone; ++ resolvedSearchTextField.autocorrectionType = UITextAutocorrectionTypeNo; ++ resolvedSearchTextField.spellCheckingType = UITextSpellCheckingTypeNo; ++ } ++ + NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"]; + NSString *searchTextChangeActionIdentifier = @"org.react-native-screens.mail-search-toolbar.text-change"; + if (resolvedSearchTextField != nil) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b4d8281b7df..dd50a292a464 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,7 +110,7 @@ patchedDependencies: react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d - react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d + react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0 uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 importers: @@ -277,7 +277,7 @@ importers: version: 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97) + version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -457,7 +457,7 @@ importers: version: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: ~4.26.0 - version: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.26.2(patch_hash=595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14519,7 +14519,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97)': + '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53)': dependencies: '@react-navigation/elements': 2.9.26(c10301b6e0c42fc6434d2b643197a81e) '@react-navigation/native': 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14527,7 +14527,7 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.26.2(patch_hash=595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -20375,7 +20375,7 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.26.2(patch_hash=595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) From 2c2fa8cd7ded97be76587ae1cfce54ee0a65f544 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:58:23 -0700 Subject: [PATCH 02/10] fix(web): pull request embed chip shows the state icon (#12951) --- apps/web/src/components/contextChipParts.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index 9f8fa9998b4e..f0a47b22e3c3 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { cn } from "~/lib/utils"; -import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, @@ -123,6 +123,9 @@ export function PullRequestChip(props: { onOpen: (event: MouseEvent, url: string) => void; }) { const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url); + const displayState = + props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state; + const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon; const button = ( ); From 2d377bfb1c8c85a61679fa6cdb6e8181cfb13626 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 21 Sep 2026 17:11:14 -0500 Subject: [PATCH 03/10] fix(web): dismiss selection actions when pressing buttons (#12950) --- apps/web/src/lib/selectionActions.test.ts | 35 +++++++++++++++++++++-- apps/web/src/lib/selectionActions.ts | 4 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/selectionActions.test.ts b/apps/web/src/lib/selectionActions.test.ts index 56f51863b690..d677b2858771 100644 --- a/apps/web/src/lib/selectionActions.test.ts +++ b/apps/web/src/lib/selectionActions.test.ts @@ -24,7 +24,12 @@ function createSelectionSurface({ interactiveActions = false } = {}) { }); const element = Object.assign(new EventTarget(), { ownerDocument: document, - contains: (target: unknown): boolean => target === element, + contains: (target: unknown): boolean => target === element || target === buttonIcon, + closest: () => null, + }); + const button = new EventTarget(); + const buttonIcon = Object.assign(new EventTarget(), { + closest: (selector: string) => (selector === "button, [role=button]" ? button : null), }); const onSelection = vi.fn(); const onDismiss = vi.fn(); @@ -58,7 +63,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { const press = event("pointerdown", { button, isPrimary }); Object.defineProperty(press, "target", { value: target }); document.dispatchEvent(press); - if (target === element && !consumed) element.dispatchEvent(press); + if (element.contains(target) && !consumed) element.dispatchEvent(press); }; const focus = (target: EventTarget) => { document.activeElement = target; @@ -92,6 +97,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { view, document, element, + buttonIcon, actionElement, field, onSelection, @@ -177,6 +183,31 @@ describe("selection action gestures", () => { expect(surface.onSelection).not.toHaveBeenCalled(); }); + it.each([1, 2])( + "dismisses selection actions when a button is pressed after %i clicks", + (detail) => { + const surface = createSelectionSurface(); + surface.down(); + surface.up({ detail }); + surface.flush(); + surface.onSelection.mockClear(); + surface.onDismiss.mockClear(); + + surface.down({ target: surface.buttonIcon }); + surface.up(); + surface.change(); + surface.flush(1000); + expect(surface.onDismiss).toHaveBeenCalledWith("interaction"); + expect(surface.onSelection).not.toHaveBeenCalled(); + + surface.down(); + surface.change(); + surface.up(); + surface.flush(); + expect(surface.onSelection).toHaveBeenCalledOnce(); + }, + ); + it("ignores nonprimary pointers and non-left mouseup without ending a left drag", () => { const surface = createSelectionSurface(); surface.down({ isPrimary: false }); diff --git a/apps/web/src/lib/selectionActions.ts b/apps/web/src/lib/selectionActions.ts index 91e6c2e6a10b..5954a770aa65 100644 --- a/apps/web/src/lib/selectionActions.ts +++ b/apps/web/src/lib/selectionActions.ts @@ -101,6 +101,10 @@ export function observeSelectionActions({ }; const onSelectionStart = (event: PointerEvent) => { if (!event.isPrimary) return; + const target = event.target as Element | null; + // Buttons can preserve an existing text selection while opening a menu. + // Their release must not reopen selection actions beside the button. + if (target?.closest("button, [role=button]")) return; // A surface may consume a press for a link or terminal mouse reporting. gestureActive = event.button === 0 && !event.defaultPrevented; dismissed = !gestureActive; From d2a90b921a5843654a58448d0bc59828e1e3ccc4 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 22 Sep 2026 08:33:28 +1000 Subject: [PATCH 04/10] fix(web): name message copy actions accurately (#12865) --- .../src/components/chat/MessageCopyButton.tsx | 4 +- .../components/chat/MessagesTimeline.test.tsx | 2 +- docs/internals/consistency-action-names.md | 39 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 docs/internals/consistency-action-names.md diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index 4a5c3f170d20..aaaa171f10de 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -37,7 +37,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ copyToClipboard(text)} ref={ref} @@ -51,7 +51,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ {isCopied ? : } -

Copy to clipboard

+

Copy message

); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dae27ccdba47..016e621be1dd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1320,7 +1320,7 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain('aria-label="Copy link"'); + expect(markup).toContain('aria-label="Copy message"'); expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-footer="true"'); }); diff --git a/docs/internals/consistency-action-names.md b/docs/internals/consistency-action-names.md new file mode 100644 index 000000000000..7277c1bbf74f --- /dev/null +++ b/docs/internals/consistency-action-names.md @@ -0,0 +1,39 @@ +# Action names agree across presentations + +Proposed invariant: a command's visible label, tooltip, menu wording and accessible +name describe the same action on the same object. An icon-only presentation must +retain that meaning. This applies to shared web/Electron controls and corresponding +React Native iOS/Android actions, wherever those actions are available. + +Separate strings can drift while each presentation still looks plausible. A message +copy action called “Copy link” promises a different clipboard payload to someone +using a screen reader. Use wording that identifies the actual target, including +when a message contains links or structured context. Additional clipboard formats +do not turn copying a message into copying a link. + +This proposal draws on Apple's [Writing](https://developer.apple.com/design/human-interface-guidelines/writing) +and [Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility) +guidance. It is a product constraint, not a claim of Apple certification or complete +application compliance. + +## Boundaries + +Native menus and assistive technologies may use different wording or omit a tooltip. +Labels need semantic agreement, not identical strings across platforms. A contextual +“Copy” is sufficient when its target is unambiguous; do not globally expand it. +Temporary “Copied” feedback may replace an action label without implying a different +target. Clipboard transport, success timing and error reporting are separate concerns. + +## Observable cases + +- User and assistant message copy controls identify the message, in both tooltip + and accessible name. Collapsing a long message does not change the action's meaning. +- Copying a message with structured context still identifies the message; it does + not promise to copy only a context link. +- Code and plan copy actions retain their own targets. A contextual plan menu can + say “Copy to clipboard”; a code control must not be announced as copying a message. +- An icon, labeled button or native menu for an equivalent command conveys the same + operation. Completed feedback never names a different object. + +Verify visible wording alongside the runtime accessible name and resulting action. +Source inspection and screenshots alone do not establish accessibility-tree behavior. From 80d9c181da41ee3890791c9b65018ce093e7bc9e Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:33:44 +0200 Subject: [PATCH 05/10] fix(contracts): old message-sent events without turnId no longer stop the server from starting (#12763) --- packages/contracts/src/orchestration.test.ts | 31 ++++++++++++++++++++ packages/contracts/src/orchestration.ts | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d89e1fb2957c..7c7bade4502e 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -812,6 +812,37 @@ it.effect("decodes thread pull request links with snapshot and stack", () => }), ); +// A stored event that fails to decode stops the event store read, and with it +// server startup, so rows written before `turnId` existed must still load. +it.effect("decodes a legacy message-sent event persisted without turnId", () => + Effect.gen(function* () { + const event = yield* decodeOrchestrationEvent({ + sequence: 539, + eventId: "event-message-legacy-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.message-sent", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-message-legacy-1", + causationEventId: null, + correlationId: "cmd-message-legacy-1", + metadata: {}, + payload: { + threadId: "thread-1", + messageId: "message-1", + role: "user", + text: "written before turn ids were recorded", + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + assert.strictEqual(event.type, "thread.message-sent"); + if (event.type !== "thread.message-sent") return; + assert.strictEqual(event.payload.turnId, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9a9ed05d4e6d..3e323e4964d5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1893,7 +1893,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), context: Schema.optional(OrchestrationMessageContext), - turnId: Schema.NullOr(TurnId), + // Events persisted before the field existed carry no key at all. + turnId: Schema.NullOr(TurnId).pipe(Schema.withDecodingDefault(Effect.succeed(null))), streaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, From e1cbb705273a261d26b489f35a7ddca6567d9663 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:34:12 +0200 Subject: [PATCH 06/10] fix(web): the custom snooze calendar starts the week where the locale does (#12745) --- .../web/src/components/CustomSnoozeDialog.tsx | 2 ++ apps/web/src/timestampFormat.test.ts | 26 +++++++++++++++++ apps/web/src/timestampFormat.ts | 29 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index d80ebf0d36fb..453efb1cbe53 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -10,6 +10,7 @@ import { import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; import { Calendar } from "./ui/calendar"; +import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -149,6 +150,7 @@ function CustomSnoozeDialog() { required selected={date} defaultMonth={date} + {...(weekStartsOn === undefined ? {} : { weekStartsOn })} disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }} onSelect={(selected) => { setDate(selected); diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 8c6287010d8f..d748853000c5 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -52,6 +52,32 @@ describe("formatShortTimestamp", () => { }); }); +describe("resolveWeekStartsOn", () => { + it.each([ + ["en-US", 0], + ["en-GB", 1], + ["pl-PL", 1], + ["ar-EG", 6], + ])("starts the %s week on weekday %i", async (locale, weekday) => { + const { resolveWeekStartsOn } = await import("./timestampFormat"); + expect(resolveWeekStartsOn(locale)).toBe(weekday); + }); + + it("leaves the default to the caller for a malformed locale", async () => { + const { resolveWeekStartsOn } = await import("./timestampFormat"); + expect(resolveWeekStartsOn("not a locale")).toBeUndefined(); + }); + + it("follows the locale the desktop host reports", async () => { + vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => "en-GB" } }); + vi.resetModules(); + const { weekStartsOn } = await import("./timestampFormat"); + expect(weekStartsOn).toBe(1); + vi.unstubAllGlobals(); + vi.resetModules(); + }); +}); + describe("formatChatTimestampTooltip", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 9dd463bb50fa..5106a00bdf2f 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -53,6 +53,35 @@ function readHostSystemLocale(): string | null { const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); +const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const; +type WeekdayIndex = (typeof WEEKDAY_INDEXES)[number]; + +type LocaleWithWeekInfo = Intl.Locale & { + readonly weekInfo?: { readonly firstDay: number }; + getWeekInfo?: () => { readonly firstDay: number }; +}; + +/** + * First weekday of a locale as a `Date#getDay` index (0 is Sunday), or + * `undefined` when the runtime has no week data, so callers keep their own + * default. Without a locale it reads the runtime's. + */ +export function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefined { + try { + const resolved: LocaleWithWeekInfo = new Intl.Locale( + locale ?? Intl.DateTimeFormat().resolvedOptions().locale, + ); + // Week info counts Monday as 1 and Sunday as 7. + const firstDay = resolved.getWeekInfo?.().firstDay ?? resolved.weekInfo?.firstDay; + return firstDay === undefined ? undefined : WEEKDAY_INDEXES[firstDay % 7]; + } catch { + return undefined; + } +} + +/** Week start for calendars, from the same locale timestamps are shown in. */ +export const weekStartsOn = resolveWeekStartsOn(timestampLocale); + const timestampFormatterCache = new Map(); function getTimestampFormatter( From 76cc9b08f19d89012f16d18f47478c4f7b9b0a6f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:22:36 +0000 Subject: [PATCH 07/10] chore(mobile): bump app version to 1.3.0 Co-authored-by: codex --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 11fb377145e3..aef812ce354b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.2.1", + version: "1.3.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay From 1262d2f3ab2940c1d47ca4f5c4ca42e5c7259765 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 18:15:07 -0700 Subject: [PATCH 08/10] feat(server): let t3.json limit or disable submodule init in new worktrees (#12953) Co-authored-by: Claude Fable 5 --- apps/server/src/vcs/GitVcsDriver.ts | 2 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 78 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 28 ++++++- apps/server/src/ws.ts | 7 ++ docs/user/project-settings.md | 4 + packages/contracts/src/environment.ts | 7 ++ packages/contracts/src/t3ProjectFile.test.ts | 6 ++ packages/contracts/src/t3ProjectFile.ts | 8 +- packages/shared/src/t3ProjectFile.test.ts | 1 + 9 files changed, 137 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2aaabeed3d7a..040862eaf6a8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -128,6 +128,8 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; + /** Fires when `.gitmodules` exists but t3.json sets `worktreeSubmodules` to `"none"`. */ + readonly onSubmodulesDisabled?: () => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f798abba74b6..045f6d15cfe6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2353,6 +2353,84 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("honors the t3.json worktreeSubmodules setting", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: "recursive" | "top-level" | "none", + branch: string, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + yield* git(cwd, ["commit", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { progress: { onSubmodulesDisabled: () => Ref.set(disabled, true) } }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: true, + inner: false, + nested: false, + }); + }), + ); + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 56cff0cb9a58..18587c9f36ef 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + T3_PROJECT_FILE_NAME, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -30,6 +31,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -3092,11 +3094,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. + // populate a submodule must not roll back the caller's thread. Repos with + // hundreds of nested submodules opt out or stop at the top level through + // t3.json, read from the checkout that was just created. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { + const submoduleMode = hasSubmodules + ? yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as("recursive" as const)) + : Effect.succeed(file.worktreeSubmodules ?? "recursive"); + }), + Effect.orElseSucceed(() => "recursive" as const), + ) + : "none"; + if (hasSubmodules && submoduleMode === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled(); + } + if (submoduleMode !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3104,7 +3124,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - ["submodule", "update", "--init", "--recursive"], + submoduleMode === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], onSubmoduleLine ? { env: { LC_ALL: "C" }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 90271e00adfb..909216d89349 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1500,6 +1500,13 @@ const makeWsRpcLayer = ( worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), + onSubmodulesDisabled: () => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + "disabled in t3.json", + ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; return submodulePath === undefined diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 0d5d7e66b1fb..6144bd960100 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -52,6 +52,10 @@ on each selected environment, and reset returns to the environment's shared list For workspace mode, a project's `t3.json` preference applies when the project has no override. Browser access changes apply when an agent session next starts. +New worktrees initialize git submodules recursively. If that step is slow because the repository +declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop +at the ones the repository declares itself, or `"none"` to leave them for a setup script. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 70888f2988e6..b5a3290f6314 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -58,6 +58,13 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ */ export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; + +/** + * How a new worktree populates git submodules: every nested level, only the + * ones this repository declares, or not at all. + */ +export const WorktreeSubmodules = Schema.Literals(["recursive", "top-level", "none"]); +export type WorktreeSubmodules = typeof WorktreeSubmodules.Type; export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; /** How a server can replace itself with another version when asked over RPC. diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index 4352449c7ade..64128a4990b0 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -60,4 +60,10 @@ describe("T3ProjectFile", () => { expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local"); expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow(); }); + + it("decodes worktreeSubmodules and rejects unknown modes", () => { + expect(decode({ worktreeSubmodules: "none" }).worktreeSubmodules).toBe("none"); + expect(decode({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe("top-level"); + expect(() => decode({ worktreeSubmodules: "shallow" })).toThrow(); + }); }); diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 6bd4168054d5..2e31e5271f7d 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ThreadEnvMode } from "./environment.ts"; +import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { ProjectScriptIcon } from "./orchestration.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ @@ -86,6 +86,12 @@ export const T3ProjectFile = Schema.Struct({ 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.', }), ), + worktreeSubmodules: Schema.optionalKey( + WorktreeSubmodules.annotate({ + description: + 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle.', + }), + ), scripts: Schema.optionalKey( Schema.Array(T3ProjectFileScript) .annotate({ diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index 924a01c4414b..42045cc4284f 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -36,6 +36,7 @@ describe("buildT3ProjectFileJsonSchema", () => { "defaultThreadEnvMode", "iconPath", "scripts", + "worktreeSubmodules", ]); expect(schema.required).toBeUndefined(); expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); From 742173a13250bcba880b700aaead34e1b0810dff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 18:15:07 -0700 Subject: [PATCH 09/10] feat(settings): resolve t3.json inside the project settings resolver (#12954) Co-authored-by: Claude Fable 5 --- .../SettingsServerControlsRouteScreen.tsx | 25 ++++-- .../settings/settings-scoped-server.test.ts | 33 ++++++++ .../settings/settings-scoped-server.ts | 15 +++- .../threads/new-task-flow-provider.tsx | 49 +++++------- .../files/projectFilesQueryState.ts | 7 +- .../settings/ProjectDefaultsSettings.tsx | 45 ++++------- .../settings/SettingInheritance.test.ts | 37 +++++++++ .../settings/SettingInheritance.tsx | 47 +++++++++--- .../settings/SettingsScopeContext.tsx | 62 ++++++++++++++- .../settings/scopedSettings.test.ts | 47 ++++++++++++ .../src/components/settings/scopedSettings.ts | 28 ++++++- .../components/settings/settingsLayout.tsx | 12 +-- apps/web/src/hooks/useHandleNewThread.test.ts | 25 +++--- apps/web/src/hooks/useHandleNewThread.ts | 29 +++---- apps/web/src/lib/t3ProjectFileDefaults.ts | 18 ++--- docs/user/project-settings.md | 4 +- packages/contracts/src/baseSchemas.ts | 25 ++++++ packages/contracts/src/settings.test.ts | 21 +++++ packages/contracts/src/settings.ts | 34 ++++++++- packages/contracts/src/t3ProjectFile.ts | 35 +++++++++ packages/shared/package.json | 4 - packages/shared/src/projectSettings.test.ts | 76 +++++++++++++++++++ packages/shared/src/projectSettings.ts | 72 +++++++++++++++++- packages/shared/src/threadEnvMode.test.ts | 65 ---------------- packages/shared/src/threadEnvMode.ts | 36 --------- 25 files changed, 615 insertions(+), 236 deletions(-) delete mode 100644 packages/shared/src/threadEnvMode.test.ts delete mode 100644 packages/shared/src/threadEnvMode.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 090467a8cd90..94312207714a 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -50,10 +50,16 @@ const PAGE_PROJECT_KEYS: Record = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or the current checkout.", + }, { mode: "local", label: "Current checkout", @@ -126,6 +132,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const value = reference.settings[key]; return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; }; + // `uniform` folds a real null into "mixed"; nullable keys need the distinction. + const isMixed = (key: keyof ServerSettings) => + reference === null || + displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "environment settings update", reportFailure: true, @@ -219,17 +229,22 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {WORKSPACE_CHOICES.map((choice, index) => ( + {WORKSPACE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( 0} disabled={disabledFor("defaultThreadEnvMode")} onPress={() => write({ defaultThreadEnvMode: choice.mode })} diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index dd589cd2038c..185fb07e602f 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -69,6 +69,39 @@ describe("mobile project settings scope", () => { expect(secondSettings.responseStreamingMode).toBe("token"); }); + it("removes a project override when a picker sends null for a key that cannot store it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true }, + }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, + }, + ]); + expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([ + { + environmentId: firstId, + patch: { + projectSettingsOverrides: { + [firstProject]: { + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultModelSelection: null, + }, + }, + }, + }, + ]); + }); + it("resets only the selected page's override and rejects environment-wide writes", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 53cc44a7ee1c..01020a3eb221 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -1,4 +1,5 @@ import { + isNullableProjectSettingsOverride, PROJECT_SCOPED_SERVER_SETTING_KEYS, type EnvironmentId, type ProjectId, @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch( continue; const current = target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; - const next = { ...current, ...patch }; + const next: Record = { ...current }; + for (const [key, value] of Object.entries(patch)) { + // A picker's "Inherit" sends null; for keys whose override cannot + // store null that means remove the override. + if ( + value === null && + !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) + ) { + delete next[key]; + } else { + next[key] = value; + } + } const overrides = writes.get(target.environment.environmentId) ?? {}; overrides[target.projectId] = next; writes.set(target.environment.environmentId, overrides); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 4a912f171a55..f2801255b3bc 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -20,10 +20,6 @@ import { } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; -import { - isDefaultThreadEnvModeSettled, - resolveDefaultThreadEnvMode, -} from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : null, ); const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; - const t3ProjectFileDefaultMode = useMemo(() => { - if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; - return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; - }, [t3ProjectFileData]); - // Environment settings with the project's overrides applied; the - // aggregate's own legacy fields still count until the server folds them. + const t3ProjectFile = useMemo( + () => + t3ProjectFileData === null || t3ProjectFileData.truncated + ? null + : parseT3ProjectFile(t3ProjectFileData.contents), + [t3ProjectFileData], + ); + // Environment settings with the project's overrides and its t3.json + // applied; the aggregate's own legacy fields still count until the server + // folds them. const projectSettings = useMemo( () => resolveProjectSettings( selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, selectedProject?.id ?? null, selectedProject, + t3ProjectFile, ), - [selectedEnvironmentServerConfig?.settings, selectedProject], + [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; - const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, - projectFile: t3ProjectFileDefaultMode, - globalDefault: projectSettings.settings.defaultThreadEnvMode, - }); - // While unsettled the resolved default is provisional. Nothing may write - // it into the draft during that window (the auto-branch effect does), or - // the frozen interim value beats the t3.json default once it loads. - const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ - explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: projectThreadEnvMode, - projectFilePending: t3ProjectFileQuery.isPending, - }); + const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // While the file read is pending and nothing above it decided, the + // resolved default is provisional. Nothing may write it into the draft + // during that window (the auto-branch effect does), or the frozen interim + // value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = + selectedProjectDraft.workspaceSelection?.mode !== undefined || + projectSettings.sources.defaultThreadEnvMode !== "environment" || + !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index b9a880301831..a6a556231e18 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = ""; const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( AsyncResult.initial(false), ).pipe(Atom.withLabel("project-file-query:empty")); -function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { +/** A pending in-app write to the file, overlaying the query until confirmed. */ +export function optimisticFileAtom( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +) { return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a08b918be4c5..d49eaa5cbf85 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -1,13 +1,12 @@ import { DEFAULT_SERVER_SETTINGS, - EnvironmentId, type ModelSelection, type ProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; -import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -75,25 +74,16 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); const modelSource = useScopedSettingSource(["defaultModelSelection"]); - const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; - - // A checkout's t3.json wins over the environment default when the project - // has no override of its own; show which one "inherit" resolves to. - const checkout = scope.kind === "checkout" ? scope.checkout : null; - // The query is disabled without a checkout, so any id satisfies the hook. - const t3File = useT3ProjectFileState( - checkout?.environmentId ?? EnvironmentId.make("none"), - category === "general" && checkout ? checkout.workspaceRoot : null, - ); - const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null; - const inheritedEnvModeLabel = - workspaceSource === "project" - ? null - : repositoryEnvMode - ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)` - : null; + // File-backed keys show their effective value; the target already carries + // the checkout's t3.json, and a null file here only fills the built-in. + // The reset arrow beside the title clears the tier (SettingsRow handles a + // project override, the environment value is cleared here), so the picker + // has no "inherit" item. + const effective = target + ? resolveProjectSettings(target.settings, null, null, null).settings + : null; function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); @@ -292,27 +282,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting title="Workspace" description={ isProjectScope - ? "Where new threads in this project start. A t3.json preference applies when the project has no override." - : "Where new threads start, unless overridden by the project or t3.json." - } - status={ - inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined + ? "Where new threads in this project start." + : "Where new threads start. Projects and their t3.json can override it." } resetAction={ - settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( + !isProjectScope && settings.defaultThreadEnvMode !== null ? ( - updateSettings({ - defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, - }) - } + onClick={() => updateSettings({ defaultThreadEnvMode: null })} /> ) : null } control={ } /> + updateSettings({ worktreeSubmodules: null })} + /> + ) : null + } + control={ + + } + /> ) : category === "source-control" ? ( <> diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 623e83056155..a351fa5fa25b 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -4,6 +4,7 @@ import { type ProjectFileBackedSettingKey, resolveEnvironmentMachineKind, type ServerSettings, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { CheckIcon, LayersIcon } from "lucide-react"; import * as Equal from "effect/Equal"; @@ -11,7 +12,7 @@ import * as Equal from "effect/Equal"; import { cn } from "../../lib/utils"; import type { EnvironmentPresentation } from "../../state/environments"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { Button, InlineButton } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -44,7 +45,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { ? "Automatic" : key === "sourceControlWriterModelSelection" ? "Text generation model" - : key === "defaultThreadEnvMode" + : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" ? "Inherit" : "Not set"; } @@ -58,6 +59,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) { return resolveEnvModeLabel(value); } + if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) { + return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules]; + } if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) { return PULL_REQUEST_MERGE_METHOD_LABELS[ value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 88f0326ea9e0..acc9b7eb7dbe 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -401,6 +401,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["default workspace mode draft local worktree"], }, + { + id: "worktree-submodules", + title: "Submodules", + to: "/settings/general", + scope: "project-defaults", + searchTerms: ["git submodule init recursive top-level none worktree t3.json"], + }, { id: "start-from-origin", title: "Start from origin", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 363b2a4583a4..b5ad7c6514d1 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -55,8 +55,11 @@ built-in default. Leave a setting on **Inherit** to let the next tier decide. Browser access changes apply when an agent session next starts. New worktrees initialize git submodules recursively. If that step is slow because the repository -declares many nested submodules, set `"worktreeSubmodules"` in `t3.json` to `"top-level"` to stop -at the ones the repository declares itself, or `"none"` to leave them for a setup script. +declares many nested submodules, set **Submodules** in **Settings → General** (with the project +selected to override it there) to **Top level only** to stop at the ones the repository declares +itself, or **Skip** to leave them for a setup script. It resolves in the same order as the +workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out +applies when the project and environment are both on **Inherit**. ## Storage cleanup diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 007d04505c86..692edfb18a65 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -835,6 +835,15 @@ describe("ServerSettings worktree defaults", () => { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults worktree submodules to inherit and tolerates unknown modes", () => { + expect(decodeServerSettings({}).worktreeSubmodules).toBeNull(); + expect(decodeServerSettings({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe( + "top-level", + ); + expect(decodeServerSettings({ worktreeSubmodules: "shallow" }).worktreeSubmodules).toBeNull(); + expect(decodeServerSettingsPatch({ worktreeSubmodules: null }).worktreeSubmodules).toBeNull(); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4a57f6680fb1..504a9423f2a3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,13 +5,14 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ForwardCompatibleNullable, + ForwardCompatibleOptional, OmittedWhenNull, ProjectId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; -import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; +import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, @@ -1001,6 +1002,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultRuntimeMode", "defaultThreadEnvMode", "newWorktreesStartFromOrigin", + "worktreeSubmodules", "defaultAutoPull", "defaultProjectScripts", "enableAgentBrowserAccess", @@ -1027,6 +1029,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), @@ -1208,6 +1211,14 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + /** + * Null defers to the repository's t3.json, then to recursive. A value + * picked on a newer server decodes as null here rather than failing the + * whole settings snapshot for an older client. + */ + worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -1499,6 +1510,7 @@ export const ServerSettingsPatch = Schema.Struct({ environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 53294a9759b3..536ac77880aa 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -90,7 +90,7 @@ export const T3ProjectFile = Schema.Struct({ worktreeSubmodules: Schema.optionalKey( WorktreeSubmodules.annotate({ description: - 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle.', + 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle. A project or environment setting in T3 Code overrides this.', }), ), scripts: Schema.optionalKey( @@ -118,6 +118,7 @@ export type T3ProjectFile = typeof T3ProjectFile.Type; */ export const PROJECT_FILE_BACKED_SETTINGS = { defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, + worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, } as const satisfies { readonly [K in ProjectScopedServerSettingKey]?: { readonly field: { diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index c4c02660a311..f59156530aa5 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -9,6 +9,7 @@ import { createModelSelection } from "./model.ts"; import { clearProjectSettingsOverrides, hasProjectSettingsOverrides, + resolveProjectFileBackedSetting, resolveProjectSettings, resolveWorktreeCleanup, withProjectSettingsOverrides, @@ -187,6 +188,23 @@ describe("resolveProjectSettings with a t3.json", () => { ).toBe("environment"); }); + it("resolves one key from the settings tier, then the file, then the built-in", () => { + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", "none", { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "none", source: "environment" }); + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", null, { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "top-level", source: "t3.json" }); + expect(resolveProjectFileBackedSetting("worktreeSubmodules", null, null)).toEqual({ + value: "recursive", + source: "environment", + }); + }); + it("leaves settings untouched when no file is passed", () => { expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe( DEFAULT_SERVER_SETTINGS, diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 967f3bc6b0c5..f99dacbbb69d 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -2,6 +2,7 @@ import { type ModelSelection, PROJECT_FILE_BACKED_SETTINGS, PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectFileBackedSettingKey, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, @@ -104,22 +105,42 @@ function applyProjectFile( ): ResolvedProjectSettings { let effective: Record | null = null; let sources: Record | null = null; - for (const [key, { field, builtIn }] of Object.entries(PROJECT_FILE_BACKED_SETTINGS)) { - const settingKey = key as ProjectScopedServerSettingKey; - if (resolved.settings[settingKey] !== null) continue; - const fromFile = projectFile?.[field]; + for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) { + if (resolved.settings[key] !== null) continue; + const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile); effective ??= { ...resolved.settings }; sources ??= { ...resolved.sources }; - effective[settingKey] = fromFile ?? builtIn; + effective[key] = value; // A project override of null defers like an unset one, so the value did // not come from the project either way. - sources[settingKey] = fromFile === undefined ? "environment" : "t3.json"; + sources[key] = source; } return effective === null || sources === null ? resolved : { ...resolved, settings: effective as ServerSettings, sources }; } +/** + * The file and built-in tiers for one key, given the project-over-environment + * value (`null` when neither is set). For callers that hold the settings tier + * but only see the file later, such as the git driver reading the t3.json of + * the checkout it just created. Same chain as `resolveProjectSettings`. + */ +export function resolveProjectFileBackedSetting( + key: K, + setting: ServerSettings[K], + projectFile: T3ProjectFile | null, +): { value: ResolvedServerSettings[K]; source: ProjectSettingSource } { + if (setting !== null) { + return { value: setting as ResolvedServerSettings[K], source: "environment" }; + } + const { field, builtIn } = PROJECT_FILE_BACKED_SETTINGS[key]; + const fromFile = projectFile?.[field] as ResolvedServerSettings[K] | undefined; + return fromFile === undefined + ? { value: builtIn as ResolvedServerSettings[K], source: "environment" } + : { value: fromFile, source: "t3.json" }; +} + function resolveProjectOverrides( settings: ServerSettings, projectId: ProjectId | null,