Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -688,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onTouchCancel={handleFeedTouchCancel}
>
<ThreadFeed
key={props.selectedThread.id}
key={selectedThreadKey}
environmentId={props.environmentId}
threadId={props.selectedThread.id}
workspaceRoot={props.threadCwd}
Expand Down
80 changes: 30 additions & 50 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
LinearTransition,
type SharedValue,
} from "react-native-reanimated";
Expand DownExpand Up@@ -132,6 +131,7 @@ import {
} from "./thread-feed-live-follow";
import {
collapsedWorkLogHeight,
ThreadDisclosureChevron,
ThreadWorkGroupToggle,
ThreadWorkLog,
THREAD_DISCLOSURE_TRANSITION_MS,
Expand DownExpand Up@@ -166,11 +166,12 @@ function formatMessageTime(input: string): string {
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
// Let neighboring rows move out of the new rows' space before showing their text.
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
THREAD_DISCLOSURE_TRANSITION_MS,
).duration(140);

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand DownExpand Up@@ -1351,16 +1352,16 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => props.onToggleTurnFold(entry.turnId)}
hitSlop={4}
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
{entry.label}
</Text>
<SymbolView
name={entry.expanded ? "chevron.down" : "chevron.right"}
<ThreadDisclosureChevron
expanded={entry.expanded}
collapsedDirection="right"
size={15}
tintColorClassName={"accent-icon-subtle"}
type="monochrome"
tintColor={iconSubtleColor}
/>
</Pressable>
);
Expand DownExpand Up@@ -1484,7 +1485,7 @@ function renderFeedEntry(
const enterAnimated = isFreshTimestamp(message.createdAt);
return (
<Animated.View
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
>
{renderedText.trim().length > 0 ? (
Expand DownExpand Up@@ -1825,7 +1826,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All@@ -1838,12 +1838,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
// whenever the viewport drifts back inside its geometric threshold, which
// yanked users off history they were reading every time a stream chunk grew
// a row. Follow breaks when the user scrolls up and away, and re-arms only
// when the list actually returns to the end (or on send / thread switch).
// a row. Scrolling away or expanding a disclosure above the end breaks
// follow; reaching the end (or sending / switching threads) re-arms it.
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const endFollowEnabledRef = useRef(true);
// A "user scroll session" spans from drag start through the end of its
// momentum; only motion inside a session can break follow, so MVCP
// momentum; scroll events only break follow inside that session, so MVCP
// compensations and programmatic scrolls never strand a follower.
const userScrollSessionRef = useRef(false);
const setEndFollow = useCallback(
Expand DownExpand Up@@ -2158,33 +2158,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
],
);
const disclosureEnteringEntryIds = useMemo(() => {
const anchorKey = disclosureAnchorKeyRef.current;
const previousPresentedFeed = previousPresentedFeedRef.current;
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
return EMPTY_DISCLOSURE_ENTRY_IDS;
}

const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
const enteringIds = new Set<string>();
if (anchorIndex < 0) {
return enteringIds;
}
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
const entryId = presentedFeed[index]!.id;
if (previousIds.has(entryId)) {
break;
}
enteringIds.add(entryId);
}
return enteringIds;
}, [disclosureToggleSettling, presentedFeed]);

useLayoutEffect(() => {
previousPresentedFeedRef.current = presentedFeed;
}, [presentedFeed]);

// The empty↔filled key below remounts the list and resets its imperative
// content-inset override. Seed the fresh instance synchronously with the
// current overlay height before the scroll integration's next reaction;
Expand DownExpand Up@@ -2271,13 +2244,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
Comment thread
juliusmarminge marked this conversation as resolved.
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
});
}, []);
}, [props.listRef, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand DownExpand Up@@ -2418,16 +2401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedWorkRows],
);

// Disclosures can mount existing offscreen rows as well as new work rows.
// Fade those in after movement; never retain removed rows over replacements.
const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) => (
<Animated.View
key={info.item.id}
entering={
disclosureEnteringEntryIds.has(info.item.id)
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
: undefined
}
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
>
{renderFeedEntry(info, {
environmentId: props.environmentId,
Expand DownExpand Up@@ -2456,7 +2436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
),
[
copiedRowId,
disclosureEnteringEntryIds,
disclosureToggleSettling,
expandedWorkRows,
terminalAssistantMessageIds,
unsettledTurnId,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
).toBe(false);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
);
});

it("re-arms at the actual end only after the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll";
readonly type: "scroll" | "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand DownExpand Up@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
Expand Down
64 changes: 47 additions & 17 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import { useIsFocused } from "@react-navigation/native";
import { useEffect, useId, useState, type ComponentProps } from "react";
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
import {
AccessibilityInfo,
AppState,
Expand DownExpand Up@@ -42,6 +42,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);

export function ThreadDisclosureChevron(props: {
readonly expanded: boolean;
readonly collapsedDirection: "right" | "down";
readonly size: number;
readonly tintColor: ColorValue;
}) {
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);

useLayoutEffect(() => {
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
duration: THREAD_DISCLOSURE_TRANSITION_MS,
reduceMotion: ReduceMotion.System,
});
}, [expandedAngle, props.expanded, rotation]);

const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));

return (
<Animated.View
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[{ width: props.size, height: props.size }, rotationStyle]}
>
<SymbolView
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
size={props.size}
tintColor={props.tintColor}
type="monochrome"
/>
</Animated.View>
);
}

function ShimmerWorkContent(props: {
readonly highlighted: boolean;
readonly icon: AppSymbolName;
Expand DownExpand Up@@ -260,7 +298,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = activities;
Expand DownExpand Up@@ -370,15 +408,11 @@ export function ThreadWorkLog(props: {
) : null}
<View className="h-4 w-4 items-center justify-center">
{canExpand ? (
<SymbolView
name={
expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
</View>
Expand DownExpand Up@@ -433,7 +467,7 @@ export function ThreadWorkGroupToggle(props: {
const icon = toolGroupSummarySymbolName(props.summaryKind);

return (
<View className="-mx-1 mb-1 px-1 py-0">
<View className="-mx-1 px-1 py-0">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: props.expanded }}
Expand DownExpand Up@@ -468,15 +502,11 @@ export function ThreadWorkGroupToggle(props: {
</Text>
</>
)}
<SymbolView
name={
props.expanded
? { ios: "chevron.up", android: "keyboard_arrow_up" }
: { ios: "chevron.down", android: "keyboard_arrow_down" }
}
<ThreadDisclosureChevron
expanded={props.expanded}
collapsedDirection="down"
size={11}
tintColor={props.iconSubtleColor}
type="monochrome"
/>
</Pressable>
</View>
Expand Down
Loading
Loading