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
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ import {
type ViewStyle,
} from "react-native";
import ImageViewing from "react-native-image-viewing";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import Animated, {
FadeIn,
FadeInDown,
FadeOut,
FadeOutDown,
LinearTransition,
} from "react-native-reanimated";
import { useThemeColor } from "../../lib/useThemeColor";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { scopedThreadKey } from "../../lib/scopedEntities";
Expand Down Expand Up @@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
const isReconnecting = props.status.kind !== "unavailable";

return (
<View className="items-center pb-2">
<Animated.View
className="absolute inset-x-0 bottom-full items-center pb-2"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadComposer.tsx:242

ComposerConnectionStatusPill is now absolutely positioned at bottom-full, the same anchor used by ComposerCommandPopover (which also has z-10). When a command popover is open while a reconnect/loading status exists, the popover renders on top of the status pill, obscuring its message and reconnect button. The previous in-flow layout reserved vertical space so the two elements never shared the same slot. Consider offsetting the status pill further (e.g. accounting for the popover height) or repositioning it so it doesn't collide with the popover.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadComposer.tsx around line 242:

`ComposerConnectionStatusPill` is now absolutely positioned at `bottom-full`, the same anchor used by `ComposerCommandPopover` (which also has `z-10`). When a command popover is open while a reconnect/loading status exists, the popover renders on top of the status pill, obscuring its message and reconnect button. The previous in-flow layout reserved vertical space so the two elements never shared the same slot. Consider offsetting the status pill further (e.g. accounting for the popover height) or repositioning it so it doesn't collide with the popover.

entering={FadeInDown.duration(180)}
exiting={FadeOutDown.duration(140)}
pointerEvents="box-none"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status pill overlays approval cards

Medium Severity

The ComposerConnectionStatusPill is now absolutely positioned (bottom-full), which removes it from the layout flow. This means it no longer reserves space, causing it to overlap with the feed end, new working rows, and pending approval/user-input cards. This overlap can also lead to it stealing presses from underlying elements.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 864aeef. Configure here.

>
<Pressable
accessibilityRole="button"
onPress={props.onPress}
Expand All @@ -250,7 +261,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
{props.status.label}
</Text>
</Pressable>
</View>
</Animated.View>
);
});

Expand Down
69 changes: 5 additions & 64 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,18 @@ import type {
ServerConfig as T3ServerConfig,
ThreadId,
} from "@t3tools/contracts";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppText as Text } from "../../components/AppText";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import type {
PendingApproval,
PendingUserInput,
Expand Down Expand Up @@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
}, [threadId, feed]);
}

// Pre-measurement estimate for the working pill's slot (the real height is
// measured via onComposerLayout since the pill lives inside the composer
// overlay). Matches the rendered pill: pt-2 + pb-2 (16) wrapping a bordered
// px-3/py-2 row (~36), so ~52 — keep it in sync with WorkingDurationPill.
const WORKING_INDICATOR_HEIGHT = 52;

const WorkingDurationPill = memo(function WorkingDurationPill(props: {
readonly startedAt: string;
}) {
const [nowMs, setNowMs] = useState(() => Date.now());

useEffect(() => {
const intervalId = setInterval(() => {
setNowMs(Date.now());
}, 1_000);
return () => clearInterval(intervalId);
}, [props.startedAt]);

const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";

return (
<Animated.View
className="shrink-0 px-4 pb-2 pt-2"
entering={FadeInDown.duration(200)}
exiting={FadeOut.duration(140)}
>
<View
className={
Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
? "self-start rounded-full border border-border bg-card px-3 py-2"
: "self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]"
}
>
<View className="flex-row items-center gap-2">
<View className="flex-row items-center gap-1">
<View className="h-1.5 w-1.5 rounded-full bg-neutral-400 dark:bg-neutral-500" />
<View className="h-1.5 w-1.5 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" />
<View className="h-1.5 w-1.5 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" />
</View>
<Text className="font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">
Working for {durationLabel}
</Text>
</View>
</View>
</Animated.View>
);
});

export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const insets = useSafeAreaInsets();
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
Expand Down Expand Up @@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const selectedThreadFeed = props.selectedThreadFeed;
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
const estimatedOverlayHeight = composerOverlapHeight;
// The overlay's measured height includes the home-indicator inset (the
// composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
// UIKit add the safe-area bottom to the content inset AGAIN — leaving a
Expand Down Expand Up @@ -411,6 +359,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
contentPresentation={props.contentPresentation}
agentLabel={agentLabel}
latestTurn={props.selectedThread.latestTurn}
activeWorkStartedAt={props.activeWorkStartedAt}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
Expand Down Expand Up @@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<Animated.View
className="w-full self-center"
layout={LinearTransition.duration(220)}
style={{ maxWidth: contentMaxWidth }}
>
{props.activeWorkStartedAt ? (
<WorkingDurationPill startedAt={props.activeWorkStartedAt} />
) : null}

<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
className="shrink-0 gap-3 px-4 pb-3"
Expand All @@ -473,7 +414,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
) : null}
</Animated.View>
) : null}
</Animated.View>
</View>

<ThreadComposer
editorRef={composerEditorRef}
Expand Down
45 changes: 43 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
import { type LegendListRef } from "@legendapp/list/react-native";
import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import { SymbolView } from "../../components/AppSymbol";
import { HeaderHeightContext } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
Expand Down Expand Up @@ -128,6 +129,7 @@ export interface ThreadFeedProps {
readonly contentPresentation: ThreadContentPresentation;
readonly agentLabel: string;
readonly latestTurn: ThreadFeedLatestTurn | null;
readonly activeWorkStartedAt: string | null;
readonly listRef: RefObject<LegendListRef | null>;
readonly freeze: SharedValue<boolean>;
readonly anchorMessageId: MessageId | null;
Expand Down Expand Up @@ -816,6 +818,10 @@ function renderFeedEntry(
const entry = info.item;
const { markdownStyles, iconSubtleColor, userBubbleColor } = props;

if (entry.type === "working") {
return <WorkingTimelineRow startedAt={entry.createdAt} />;
}

if (entry.type === "turn-fold") {
return (
<Pressable
Expand Down Expand Up @@ -993,6 +999,32 @@ function renderFeedEntry(
);
}

const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) {
const [nowMs, setNowMs] = useState(() => Date.now());

useEffect(() => {
const intervalId = setInterval(() => {
setNowMs(Date.now());
}, 1_000);
return () => clearInterval(intervalId);
}, [props.startedAt]);

const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";

return (
<View className="mb-4 flex-row items-center gap-2 px-1.5 py-1">
<View className="flex-row items-center gap-1">
<View className="h-1 w-1 rounded-full bg-neutral-400 dark:bg-neutral-500" />
<View className="h-1 w-1 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" />
<View className="h-1 w-1 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" />
</View>
<Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400">
Working for {durationLabel}
</Text>
</View>
);
});

function UserMessageContent(props: {
readonly text: string;
readonly markdownStyles: MarkdownStyleSet;
Expand Down Expand Up @@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
expandedTurnIds,
expandedWorkGroupIds,
props.activeWorkStartedAt,
),
[expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
[
expandedTurnIds,
expandedWorkGroupIds,
props.activeWorkStartedAt,
props.feed,
props.latestTurn,
],
);

// The empty↔filled key below remounts the list, which resets its imperative
Expand Down Expand Up @@ -1761,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}}
/>
</View>
{props.feed.length === 0 && props.contentPresentation.kind === "ready" ? (
{props.feed.length === 0 &&
props.activeWorkStartedAt === null &&
props.contentPresentation.kind === "ready" ? (
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<ThreadFeedPlaceholder
title="No conversation yet"
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,20 @@ describe("buildThreadFeed", () => {
});
});

it("appends active work as a normal timeline row", () => {
const startedAt = "2026-04-01T00:00:01.000Z";
const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt);

expect(presented).toEqual([
{
type: "working",
id: "working-indicator-row",
createdAt: startedAt,
},
]);
expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]);
});

it("models work-log overflow as list rows", () => {
const activity = (
id: string,
Expand Down
18 changes: 16 additions & 2 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ type RawThreadFeedEntry =

export type ThreadFeedEntry =
| Extract<RawThreadFeedEntry, { type: "message" }>
| {
readonly type: "working";
readonly id: string;
readonly createdAt: string;
}
| {
readonly type: "activity-group";
readonly id: string;
Expand Down Expand Up @@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation(
latestTurn: ThreadFeedLatestTurn | null,
expandedTurnIds: ReadonlySet<TurnId>,
expandedWorkGroupIds: ReadonlySet<string> = new Set(),
activeWorkStartedAt: string | null = null,
): ThreadFeedEntry[] {
const sourceFeed = feed.filter(
(entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle",
(entry) =>
entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working",
);
const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
const collapsedEntryIds = new Set<string>();
Expand Down Expand Up @@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation(
appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
}
}
if (activeWorkStartedAt !== null) {
result.push({
type: "working",
id: "working-indicator-row",
createdAt: activeWorkStartedAt,
});
}
return result;
}

function appendPresentedFeedEntry(
result: ThreadFeedEntry[],
entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>,
entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "working" }>,
expandedWorkGroupIds: ReadonlySet<string>,
): void {
if (entry.type !== "activity-group") {
Expand Down
Loading