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
28 changes: 28 additions & 0 deletions apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function SettingsThreadsRouteScreen() {
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
<AutoSettleSettingsRows />
<TimelineSettingsSection />
<LegacySettingsSection />
</ScrollView>
</SettingsScreen>
Expand Down Expand Up @@ -215,6 +216,33 @@ function AutoSettleSettingsRows() {
);
}

/**
* Device-local counterpart of web's "Sent message position" client setting.
*/
function TimelineSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const preferences = useAtomValue(mobilePreferencesAtom);
const anchorsAtTop =
AsyncResult.isSuccess(preferences) && preferences.value.chatTurnAnchor === "top";

return (
<View className="gap-3">
<SettingsSection title="Timeline">
<SettingsSwitchRow
icon="arrow.up"
label="Sent messages at top"
value={anchorsAtTop}
onValueChange={(value) => savePreferences({ chatTurnAnchor: value ? "top" : "bottom" })}
/>
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
Holds each message you send at the top of the screen so the reply reads downward. Off keeps
the conversation pinned to the latest message.
</Text>
</View>
);
}

/**
* Device-local legacy toggles. Mobile has no client-settings sync, so this is
* the counterpart of web's Settings → General → Legacy features backed by
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
CodexFeedbackSubmission,
EnvironmentThreadStatus,
} from "@t3tools/client-runtime/state/threads";
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import type { LegendListRef } from "@legendapp/list/react-native";
Expand Down Expand Up @@ -75,6 +77,7 @@ import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
import { deviceEnvironment } from "../../state/device";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useEnvironmentQuery } from "../../state/query";
import { threadDevicePreviews } from "../devices/threadDevicePreviews";
import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
Expand Down Expand Up @@ -337,6 +340,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
);
const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null);
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const chatTurnAnchor =
(AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.chatTurnAnchor : null) ??
"bottom";
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
// Android keys the safe-area padding on keyboard visibility (#5988): the
// back gesture closes the keyboard while the editor stays focused, and a
Expand Down Expand Up @@ -790,12 +797,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
hasStartedTurn: props.selectedThread.latestTurn !== null,
hasUserMessage,
queuedMessageCount: props.selectedThreadQueueCount,
turnAnchor: chatTurnAnchor,
}),
);
composerEditorRef.current?.blur();
return messageId;
}, [
anchorMessageId,
chatTurnAnchor,
clearUsageLimitsFor,
props.onSendMessage,
props.selectedThread.latestTurn,
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => {
hasStartedTurn: false,
hasUserMessage: false,
queuedMessageCount: 0,
turnAnchor: "bottom",
}),
).toBe("first-message");
});
Expand All @@ -103,6 +104,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => {
hasStartedTurn: false,
hasUserMessage: false,
queuedMessageCount: 1,
turnAnchor: "bottom",
}),
).toBe("first-message");
});
Expand All @@ -115,6 +117,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => {
hasStartedTurn: false,
hasUserMessage: false,
queuedMessageCount: 0,
turnAnchor: "bottom",
}),
).toBe("first-message");
});
Expand All @@ -127,6 +130,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => {
hasStartedTurn: false,
hasUserMessage: true,
queuedMessageCount: 0,
turnAnchor: "bottom",
}),
).toBeNull();
});
Expand All @@ -139,9 +143,36 @@ describe("resolveThreadFeedSubmissionAnchor", () => {
hasStartedTurn: true,
hasUserMessage: false,
queuedMessageCount: 0,
turnAnchor: "bottom",
}),
).toBeNull();
});

it("re-anchors every direct send when messages anchor at the top", () => {
expect(
resolveThreadFeedSubmissionAnchor({
currentAnchorMessageId: "first-message",
submittedMessageId: "second-message",
hasStartedTurn: true,
hasUserMessage: true,
queuedMessageCount: 0,
turnAnchor: "top",
}),
).toBe("second-message");
});

it("keeps the current framing for a send that queues behind others", () => {
expect(
resolveThreadFeedSubmissionAnchor({
currentAnchorMessageId: "first-message",
submittedMessageId: "second-message",
hasStartedTurn: true,
hasUserMessage: true,
queuedMessageCount: 1,
turnAnchor: "top",
}),
).toBe("first-message");
});
});

describe("resolveThreadFeedLiveFollow", () => {
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ChatTurnAnchor } from "@t3tools/contracts";

export type ThreadFeedLiveFollowEvent =
| { readonly type: "reset" }
| { readonly type: "user-scroll-begin" }
Expand Down Expand Up @@ -47,13 +49,24 @@ export function shouldFollowThreadWorkGroupAppend(input: {
);
}

/**
* Which sent message the feed should hold near the top while its turn streams.
* `bottom` anchors only the first message of a thread; `top` re-anchors every
* direct send. A message that queues behind others is not the live turn, so it
* leaves the current framing alone either way.
*/
export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: {
readonly currentAnchorMessageId: AnchorId | null;
readonly submittedMessageId: AnchorId;
readonly hasStartedTurn: boolean;
readonly hasUserMessage: boolean;
readonly queuedMessageCount: number;
readonly turnAnchor: ChatTurnAnchor;
}): AnchorId | null {
if (input.turnAnchor === "top") {
return input.queuedMessageCount > 0 ? input.currentAnchorMessageId : input.submittedMessageId;
}
Comment on lines +66 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the viewport still for the first queued mobile follow-up

When the agent is already running and the mobile outbox is initially empty, selectedThreadQueueCount is captured as 0 before onSendMessage synchronously enqueues the new message. The top branch consequently returns the new message ID and scrolls that queued row to the top even though it is waiting behind the active turn, contradicting the intended behavior that queued messages retain the current framing. The decision needs to account for an active turn, not only messages already present in the queue.

Useful? React with 👍 / 👎.


if (input.hasStartedTurn || input.hasUserMessage) {
return null;
}
Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts";
import type {
ChatTurnAnchor,
ProviderInstanceId,
SidebarProjectGroupingMode,
} from "@t3tools/contracts";
import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme";
import * as MobileDatabase from "./mobile-database";
Expand All @@ -30,6 +34,8 @@ export interface Preferences {
readonly collapsedProjectGroups?: readonly string[];
/** What the Return key does in the composer on a hardware keyboard. iOS only. */
readonly composerEnterBehavior?: ComposerEnterBehavior;
/** Device-local counterpart of the web client's `chatTurnAnchor` setting. */
readonly chatTurnAnchor?: ChatTurnAnchor;
/** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
readonly projectGroupingEnabled?: boolean;
readonly projectGroupingMode?: SidebarProjectGroupingMode;
Expand Down Expand Up @@ -97,6 +103,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
collapsedProjectGroups?: readonly string[];
composerEnterBehavior?: ComposerEnterBehavior;
chatTurnAnchor?: ChatTurnAnchor;
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
planModeEnabled?: boolean;
Expand Down Expand Up @@ -157,6 +164,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") {
preferences.composerEnterBehavior = parsed.composerEnterBehavior;
}
if (parsed.chatTurnAnchor === "bottom" || parsed.chatTurnAnchor === "top") {
preferences.chatTurnAnchor = parsed.chatTurnAnchor;
}
if (typeof parsed.projectGroupingEnabled === "boolean") {
preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
}
Expand Down
Loading
Loading