Skip to content

Commit a51b8d2

Browse files
Merge main into fix/issue-15135
2 parents 491622c + 3b95e78 commit a51b8d2

59 files changed

Lines changed: 1777 additions & 234 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ private struct ComposerTokenPayload: Decodable {
1313
let end: Int
1414
}
1515

16-
private struct ComposerSelectionPayload: Decodable {
16+
private struct ComposerSelectionPayload: Decodable, Equatable {
1717
let start: Int
1818
let end: Int
1919
}
@@ -449,6 +449,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
449449
private var tokensNeedRebuild = false
450450
private var chipsNeedMeasuredWidth = false
451451
private var hasDeferredForcedRebuild = false
452+
private var pendingContentOffset: CGPoint?
453+
private var needsSelectionReveal = false
454+
private var selectionBeforeFocusingTap: (selection: ComposerSelectionPayload, value: String)?
452455

453456
let onComposerChange = EventDispatcher()
454457
let onComposerSelectionChange = EventDispatcher()
@@ -520,6 +523,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
520523
@objc private func openContext(_ recognizer: UITapGestureRecognizer) {
521524
guard let (index, attachment) = contextAttachment(at: recognizer.location(in: textView)) else {
522525
textView.becomeFirstResponder()
526+
if selectionBeforeFocusingTap != nil {
527+
// UIKit places the caret from its own tap recognizer during this same touch.
528+
DispatchQueue.main.async { [weak self] in
529+
self?.restoreSelectionBeforeFocusingTap()
530+
}
531+
}
523532
return
524533
}
525534
openContext(index: index, attachment: attachment)
@@ -558,7 +567,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
558567
}
559568

560569
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
561-
true
570+
// The collapsed editor only shows the draft's first line, so a tap that expands an
571+
// overflowing draft returns to the caret it was left at instead of moving it there.
572+
let collapsedOverflow = !textView.isFirstResponder && !textView.isScrollEnabled && draftOverflowsBounds()
573+
selectionBeforeFocusingTap = collapsedOverflow ? (sourceSelection(), value) : nil
574+
return true
562575
}
563576

564577
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -583,11 +596,27 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
583596

584597
public override func layoutSubviews() {
585598
super.layoutSubviews()
599+
let previousSize = textView.bounds.size
586600
textView.frame = bounds
587601
if chipsNeedMeasuredWidth, bounds.width > 0 {
588602
chipsNeedMeasuredWidth = false
589603
applyControlledDocument(force: true)
590604
}
605+
if textView.isScrollEnabled, bounds.width > 0, bounds.height > 0 {
606+
textView.layoutIfNeeded()
607+
if let offset = pendingContentOffset {
608+
let minY = -textView.adjustedContentInset.top
609+
let maxY = max(minY, textView.contentSize.height - bounds.height + textView.adjustedContentInset.bottom)
610+
textView.setContentOffset(CGPoint(x: offset.x, y: min(maxY, max(minY, offset.y))), animated: false)
611+
pendingContentOffset = nil
612+
}
613+
if needsSelectionReveal || (previousSize != bounds.size && textView.isFirstResponder) {
614+
needsSelectionReveal = false
615+
if let selection = textView.selectedTextRange {
616+
textView.scrollRectToVisible(textView.caretRect(for: selection.end), animated: false)
617+
}
618+
}
619+
}
591620
let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding
592621
let placeholderY = textView.textContainerInset.top
593622
let placeholderWidth = max(
@@ -632,10 +661,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
632661
tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? []
633662
tokensNeedRebuild = true
634663
}
664+
let previousSelection = sourceSelection()
635665
value = document.value
636666
requestedSelection = document.selection
637667
applyControlledDocument(force: tokensNeedRebuild)
638668
applyRequestedSelection()
669+
cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection)
639670
if tokensMatchCurrentValue() {
640671
tokensNeedRebuild = false
641672
}
@@ -694,7 +725,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
694725
}
695726

696727
func setScrollEnabled(_ scrollEnabled: Bool) {
728+
guard textView.isScrollEnabled != scrollEnabled else { return }
729+
if !scrollEnabled {
730+
// Disabling scrolling discards UITextView's offset. Restore it after
731+
// expansion has laid out the text, regardless of prop update order.
732+
pendingContentOffset = pendingContentOffset ?? textView.contentOffset
733+
}
697734
textView.isScrollEnabled = scrollEnabled
735+
if scrollEnabled {
736+
revealSelectionAfterLayout()
737+
}
698738
}
699739

700740
func setAutoFocus(_ autoFocus: Bool) {
@@ -738,8 +778,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
738778
}
739779

740780
func setSelection(start: Int, end: Int) {
781+
let previousSelection = sourceSelection()
741782
requestedSelection = ComposerSelectionPayload(start: start, end: end)
742783
applyRequestedSelection()
784+
cancelFocusingTapRestoreIfSelectionMoved(from: previousSelection)
743785
}
744786

745787
public func textViewDidChange(_ textView: UITextView) {
@@ -811,6 +853,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
811853
}
812854

813855
public func textViewDidBeginEditing(_ textView: UITextView) {
856+
revealSelectionAfterLayout()
814857
onComposerFocus()
815858
}
816859

@@ -836,13 +879,21 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
836879
hasDeferredForcedRebuild = false
837880

838881
let previousSelection = sourceSelection()
882+
if textView.isScrollEnabled {
883+
pendingContentOffset = pendingContentOffset ?? textView.contentOffset
884+
}
839885
isApplyingControlledValue = true
840886
textView.attributedText = makeAttributedDocument()
841887
let targetSelection = requestedSelection ?? previousSelection
842888
requestedSelection = nil
843889
textView.selectedRange = displayRange(for: targetSelection)
844890
restoreBaseTypingAttributes()
845891
isApplyingControlledValue = false
892+
if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end {
893+
revealSelectionAfterLayout()
894+
} else {
895+
setNeedsLayout()
896+
}
846897
updatePlaceholderVisibility()
847898
emitContentSizeIfNeeded()
848899
}
@@ -1053,6 +1104,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
10531104
return
10541105
}
10551106
value = textView.serializedText()
1107+
revealSelectionAfterLayout()
10561108
let selection = sourceSelection()
10571109
nativeEventCount += 1
10581110
onComposerChange([
@@ -1122,6 +1174,40 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
11221174
isApplyingControlledValue = true
11231175
textView.selectedRange = nextRange
11241176
isApplyingControlledValue = false
1177+
revealSelectionAfterLayout()
1178+
}
1179+
1180+
private func restoreSelectionBeforeFocusingTap() {
1181+
guard let snapshot = selectionBeforeFocusingTap else {
1182+
return
1183+
}
1184+
selectionBeforeFocusingTap = nil
1185+
// A draft that changed during the touch, such as a transcript landing, owns the caret now.
1186+
guard textView.isFirstResponder, textView.markedTextRange == nil, value == snapshot.value else {
1187+
return
1188+
}
1189+
let range = displayRange(for: snapshot.selection)
1190+
guard !NSEqualRanges(range, textView.selectedRange) else {
1191+
return
1192+
}
1193+
textView.selectedRange = range
1194+
revealSelectionAfterLayout()
1195+
}
1196+
1197+
private func cancelFocusingTapRestoreIfSelectionMoved(from previousSelection: ComposerSelectionPayload) {
1198+
if sourceSelection() != previousSelection {
1199+
selectionBeforeFocusingTap = nil
1200+
}
1201+
}
1202+
1203+
private func draftOverflowsBounds() -> Bool {
1204+
let size = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude))
1205+
return size.height > textView.bounds.height + 1
1206+
}
1207+
1208+
private func revealSelectionAfterLayout() {
1209+
needsSelectionReveal = true
1210+
setNeedsLayout()
11251211
}
11261212

11271213
private func updatePlaceholderVisibility() {

‎apps/mobile/src/Stack.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1059,18 +1059,34 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig
10591059
const nativeMetrics = useNativeLayoutMetrics();
10601060
const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView;
10611061
const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView;
1062+
const flowPresentations = useRef(new Map<string, boolean>());
10621063

10631064
return (
10641065
<Navigator
10651066
screenLayout={(props) => <GuardedScreenLayout {...props} />}
1067+
screenListeners={{
1068+
state: ({ data }) => {
1069+
const keys = new Set(data.state.routes.map((route) => route.key));
1070+
for (const key of flowPresentations.current.keys()) {
1071+
if (!keys.has(key)) flowPresentations.current.delete(key);
1072+
}
1073+
},
1074+
}}
10661075
screenOptions={({ route }) => {
10671076
if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
10681077
return Platform.OS === "android"
10691078
? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) }
10701079
: {};
10711080
}
10721081

1073-
return usesWorkspaceFlowScreens
1082+
// Moving an open flow between a sheet and a split column remounts its nested
1083+
// navigator. Keep its presentation until dismissal so folding preserves the draft.
1084+
let usesCard = flowPresentations.current.get(route.key);
1085+
if (usesCard === undefined) {
1086+
usesCard = usesWorkspaceFlowScreens;
1087+
flowPresentations.current.set(route.key, usesCard);
1088+
}
1089+
return usesCard
10741090
? { presentation: "card" }
10751091
: {
10761092
presentation: "formSheet",

‎apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function WorkspaceEmptyDetail(props: {
6565
className="mt-2 flex-row items-center gap-2 rounded-full bg-primary px-5 py-3 active:opacity-70"
6666
onPress={props.onStartNewTask}
6767
>
68-
<Text className="text-base font-t3-bold text-primary-foreground">New Task</Text>
68+
<Text className="text-base font-t3-bold text-primary-foreground">New thread</Text>
6969
</Pressable>
7070
)
7171
) : null}

‎apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,8 @@ function WorkspaceColumns(
282282
<WorkspaceEmptyDetail
283283
tab={selectedTab}
284284
onStartNewTask={
285-
// The detail bar or a visible sidebar already carries compose.
286-
detailCompose || panes.primarySidebarVisible ? undefined : openNewTask
285+
// Duo provides compose in the detail bar; iPad uses the empty-state action.
286+
detailCompose ? undefined : openNewTask
287287
}
288288
/>
289289
</ColumnContent>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2+
import {
3+
CHATGPT_USAGE_LIMIT_CODE,
4+
CHATGPT_USAGE_URL,
5+
CHATGPT_WORKSPACE_ADMIN_REQUEST,
6+
CHATGPT_WORKSPACE_USAGE_LIMIT_CODE,
7+
} from "@t3tools/shared/usageLimits";
8+
import * as Clipboard from "expo-clipboard";
9+
import { useState } from "react";
10+
import { Linking, View } from "react-native";
11+
import { ProviderIcon } from "../../components/ProviderIcon";
12+
import { AppText as Text } from "../../components/ui/AppText";
13+
import { Button } from "../../components/ui/Button";
14+
15+
export function ChatGptUsageLimitNotice({ thread }: { thread: EnvironmentThreadShell }) {
16+
const [status, setStatus] = useState<"copied" | "copy-failed" | "open-failed" | null>(null);
17+
const code = thread.runtime?.lastErrorCode;
18+
const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE;
19+
if (thread.runtime?.status !== "failed" || (!workspaceLimit && code !== CHATGPT_USAGE_LIMIT_CODE))
20+
return null;
21+
return (
22+
<View
23+
accessibilityRole="alert"
24+
className="mx-3 mb-2 gap-2 rounded-xl border border-border-subtle bg-screen p-3"
25+
>
26+
<View className="flex-row items-center gap-2">
27+
<ProviderIcon provider="codex" size={16} />
28+
<Text className="text-sm font-t3-medium text-foreground">ChatGPT usage limit reached</Text>
29+
</View>
30+
<Text className="text-xs text-foreground-muted">
31+
{workspaceLimit
32+
? status === "copied"
33+
? "Request copied. Send it to your workspace admin."
34+
: "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits."
35+
: "Review your usage settings in ChatGPT."}
36+
</Text>
37+
{workspaceLimit ? (
38+
<Button
39+
label="Copy request for admin"
40+
tone="primary"
41+
onPress={() =>
42+
void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST).then(
43+
() => setStatus("copied"),
44+
() => setStatus("copy-failed"),
45+
)
46+
}
47+
/>
48+
) : (
49+
<Button
50+
label="Manage usage"
51+
tone="primary"
52+
onPress={() =>
53+
void Linking.openURL(CHATGPT_USAGE_URL).then(
54+
() => setStatus(null),
55+
() => setStatus("open-failed"),
56+
)
57+
}
58+
/>
59+
)}
60+
{status === "copy-failed" || status === "open-failed" ? (
61+
<Text accessibilityRole="alert" className="text-xs text-destructive">
62+
{status === "copy-failed"
63+
? "Could not copy the request. Try again."
64+
: "Could not open ChatGPT. Go to chatgpt.com → Settings → Usage."}
65+
</Text>
66+
) : null}
67+
</View>
68+
);
69+
}

‎apps/mobile/src/features/threads/NewTaskDraftScreen.tsx‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import {
2121
usePreventRemove,
2222
type NavigationAction,
2323
} from "@react-navigation/native";
24-
import { HeaderHeightContext } from "@react-navigation/elements";
25-
import { useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
26-
import { Platform, Pressable, ScrollView, View } from "react-native";
24+
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
25+
import { Platform, Pressable, View } from "react-native";
26+
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
2727
import {
2828
KeyboardController,
2929
KeyboardStickyView,
@@ -215,9 +215,6 @@ export function NewTaskDraftScreen(props: {
215215
reserveShare,
216216
} = useIncomingShare();
217217
const insets = useSafeAreaInsets();
218-
// The iOS glass header floats over the hero, and its height differs between an
219-
// iPhone sheet and an iPad column that also carries the status bar.
220-
const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 56;
221218
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
222219
const controlsBottomPadding = Math.max(insets.bottom, 10);
223220
const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
@@ -1553,11 +1550,8 @@ export function NewTaskDraftScreen(props: {
15531550
<ScrollView
15541551
alwaysBounceVertical={isKeyboardVisible}
15551552
className="flex-1"
1556-
contentInsetAdjustmentBehavior="never"
1553+
contentInsetAdjustmentBehavior="automatic"
15571554
contentContainerClassName="grow items-center pb-[236px] pt-12"
1558-
contentContainerStyle={
1559-
Platform.OS === "ios" ? { paddingTop: navigationHeaderHeight + 16 } : undefined
1560-
}
15611555
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
15621556
keyboardShouldPersistTaps="handled"
15631557
showsVerticalScrollIndicator={false}
@@ -1851,6 +1845,7 @@ export function NewTaskDraftScreen(props: {
18511845
headerBackVisible: false,
18521846
headerShadowVisible: false,
18531847
title: "New thread",
1848+
unstable_navigationItemStyle: "editor",
18541849
}}
18551850
/>
18561851
<SheetHeader

0 commit comments

Comments
 (0)