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
50 changes: 40 additions & 10 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
});
const canSend =
hasContent &&
!props.sessionInputBlocked &&
composerAuthority.providerAdmissionAvailable &&
props.projectCwd !== null &&
attachmentBlockReason === null &&
Expand All @@ -624,6 +625,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
activeSessionProviderStatus?.requiresNewThreadForModelChange === true);
const getModelChangeDisabledReason = useCallback(
(option: ModelOption) => {
if (props.sessionInputBlocked) {
return "Provider changes are blocked while this thread has a pending safety operation";
}
const boundInstanceId = props.selectedThread.session?.providerInstanceId;
if (boundInstanceId) {
const transition = resolveProviderContinuationTransition({
Expand Down Expand Up @@ -651,13 +655,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
? "Start a new thread to use this model"
: undefined;
},
[currentModelSelection, modelChangesLocked, props.selectedThread.session, props.serverConfig],
[
currentModelSelection,
modelChangesLocked,
props.selectedThread.session,
props.serverConfig,
props.sessionInputBlocked,
],
);
const quickQuestionAvailable = canOpenQuickQuestion({
connectionState: props.connectionState,
session: props.selectedThread.session,
provider: activeSessionProviderStatus,
});
const quickQuestionAvailable =
!props.sessionInputBlocked &&
canOpenQuickQuestion({
connectionState: props.connectionState,
session: props.selectedThread.session,
provider: activeSessionProviderStatus,
});
const quickQuestionScopeKey = quickQuestionSessionScopeKey({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
Expand Down Expand Up @@ -824,12 +836,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
props.localOutboxCount === 0 &&
supportsSessionInputQueueFollowUp(activeSessionProviderStatus);
const canClearSessionInputQueue =
!props.sessionInputBlocked &&
props.connectionState === "connected" &&
props.selectedThread.session?.status === "running" &&
props.selectedThread.session.activeTurnId != null &&
sessionQueueCount > 0 &&
supportsSessionInputQueueClear(activeSessionProviderStatus);
const canRemoveOnlySessionInputQueueItem =
!props.sessionInputBlocked &&
props.connectionState === "connected" &&
props.selectedThread.session?.status === "running" &&
props.selectedThread.session.activeTurnId != null &&
Expand All @@ -845,6 +859,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
sessionInputQueueMutation?.scopeKey === sessionInputQueueScopeKey;
const canSetSessionInputQueueModes =
showSessionInputQueueModes &&
!props.sessionInputBlocked &&
props.connectionState === "connected" &&
composerAuthority.providerAdmissionAvailable &&
(props.selectedThread.session?.status === "ready" ||
Expand Down Expand Up @@ -1316,6 +1331,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
sessionResources: props.sessionResources,
showInteractionModeToggle,
hasThread: true,
enabled: !props.sessionInputBlocked,
onChangeDraftMessage: props.onChangeDraftMessage,
onUpdateInteractionMode: props.onUpdateInteractionMode,
});
Expand Down Expand Up @@ -1543,12 +1559,19 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
environmentId: props.environmentId,
providerGroups: threadProviderGroups,
selectedModel: currentModelSelection,
onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
onSelectModel: (option) => {
if (!props.sessionInputBlocked) props.onUpdateModelSelection(option.selection);
},
optionDescriptors: providerOptionDescriptors,
onUpdateOptionSelections: (options) =>
props.onUpdateModelSelection({ ...currentModelSelection, options }),
onUpdateOptionSelections: (options) => {
if (!props.sessionInputBlocked) {
props.onUpdateModelSelection({ ...currentModelSelection, options });
}
},
runtimeMode: currentRuntimeMode,
onUpdateRuntimeMode: props.onUpdateRuntimeMode,
onUpdateRuntimeMode: (mode) => {
if (!props.sessionInputBlocked) props.onUpdateRuntimeMode(mode);
},
getModelDisabledReason: getModelChangeDisabledReason,
}),
[
Expand All @@ -1558,6 +1581,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
getModelChangeDisabledReason,
props.onUpdateModelSelection,
props.onUpdateRuntimeMode,
props.sessionInputBlocked,
providerOptionDescriptors,
settingsOwnerId,
threadProviderGroups,
Expand Down Expand Up @@ -1866,6 +1890,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
label={currentModelOption?.label ?? currentModelSelection.model}
maxWidth={152}
disabled={props.sessionInputBlocked}
accessibilityHint={
props.sessionInputBlocked
? "Provider changes are blocked while this thread has a pending safety operation"
: undefined
}
onPress={openSettings}
/>
{sessionHarnessRefinementActions.length > 0 ? (
Expand Down
97 changes: 96 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
import { isRollbackActive, type RollbackTarget } from "@t3tools/client-runtime/rollback";
import { getMobileRollbackStatusPresentation } from "./rollback-status-presentation";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import type { LegendListRef } from "@legendapp/list/react-native";
import { HeaderHeightContext } from "@react-navigation/elements";
Expand All @@ -20,6 +22,7 @@ import type {
MessageId,
ModelSelection,
OrchestrationThreadShell,
OrchestrationRollbackStatus,
ProviderApprovalDecision,
ProviderAskSessionSideQuestionResult,
ProviderInteractionMode,
Expand Down Expand Up @@ -47,6 +50,8 @@ import {
AppState,
Keyboard,
Platform,
Pressable,
Text,
useWindowDimensions,
View,
type GestureResponderEvent,
Expand Down Expand Up @@ -144,6 +149,14 @@ export interface ThreadDetailScreenProps {
readonly threadSyncStatus?: EnvironmentThreadStatus;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
readonly rollbackStatus: OrchestrationRollbackStatus | null | undefined;
readonly rollbackTargets: ReadonlyMap<string, RollbackTarget>;
readonly rollbackTargetIdle: boolean;
readonly rollbackCommandPending: boolean;
readonly onRevertMessage: (target: RollbackTarget) => void;
readonly onRecoverRollback: (
action: "retry-verification" | "resume-compensation",
) => Promise<void>;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
Expand Down Expand Up @@ -287,6 +300,56 @@ const USER_INPUT_TOGGLE_TIMING = {
easing: Easing.out(Easing.cubic),
};

function RollbackStatusSurface(props: {
readonly status: OrchestrationRollbackStatus | null | undefined;
readonly pending: boolean;
readonly onRecover: (action: "retry-verification" | "resume-compensation") => Promise<void>;
}) {
if (!props.status) return null;
const presentation = getMobileRollbackStatusPresentation(props.status);
const { severe, title, actions } = presentation;
return (
<View
accessibilityRole={presentation.accessibilityRole}
accessibilityLiveRegion={presentation.accessibilityLiveRegion}
className={`mb-3 gap-2 rounded-2xl border px-3 py-2.5 ${
severe ? "border-red-500/50 bg-red-500/10" : "border-adaptive-neutral-300-700 bg-screen"
}`}
>
<Text className={`font-t3-semibold text-sm ${severe ? "text-red-500" : "text-foreground"}`}>
{title}
</Text>
<Text className="font-t3-regular text-xs text-foreground-muted">{presentation.detail}</Text>
{actions.length > 0 ? (
<View className="flex-row flex-wrap gap-2">
{actions.includes("retry-verification") ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry rollback verification"
disabled={props.pending}
onPress={() => void props.onRecover("retry-verification")}
className="min-h-11 justify-center rounded-xl border border-adaptive-neutral-300-700 px-3 disabled:opacity-50"
>
<Text className="font-t3-medium text-sm text-foreground">Retry verification</Text>
</Pressable>
) : null}
{actions.includes("resume-compensation") ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Resume rollback compensation"
disabled={props.pending}
onPress={() => void props.onRecover("resume-compensation")}
className="min-h-11 justify-center rounded-xl border border-adaptive-neutral-300-700 px-3 disabled:opacity-50"
>
<Text className="font-t3-medium text-sm text-foreground">Resume compensation</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
);
}

export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const insets = useSafeAreaInsets();
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
Expand Down Expand Up @@ -814,6 +877,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onEndFollowEnabledChange={setEndFollowEnabled}
skills={selectedProviderSkills}
onUseArtifactTemplate={handleUseArtifactTemplate}
rollbackTargets={props.rollbackTargets}
rollbackTargetIdle={props.rollbackTargetIdle}
rollbackCommandPending={props.rollbackCommandPending}
onRevertMessage={props.onRevertMessage}
loadEarlier={props.loadEarlier ?? null}
/>
</View>
Expand Down Expand Up @@ -861,6 +928,31 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
</View>
) : null}

<View className="px-4">
<RollbackStatusSurface
status={props.rollbackStatus}
pending={props.rollbackCommandPending}
onRecover={props.onRecoverRollback}
/>
{props.localOutboxCount > 0 &&
props.rollbackTargets.size > 0 &&
!isRollbackActive(props.rollbackStatus) ? (
<View
accessibilityRole="summary"
className="mb-3 rounded-xl border border-adaptive-neutral-300-700 bg-screen px-3 py-2"
>
<Text className="text-sm font-semibold text-foreground">
Rollback paused for queued messages
</Text>
<Text className="mt-1 text-xs leading-5 text-foreground-muted">
Send or cancel the queued{" "}
{props.localOutboxCount === 1 ? "message" : "messages"} before starting
rollback.
</Text>
</View>
) : null}
</View>

{props.activePendingApproval ||
props.activePendingUserInput ||
props.activePendingInteraction ? (
Expand Down Expand Up @@ -946,7 +1038,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
sessionInputBlocked={
props.activePendingApproval !== null ||
props.activePendingUserInput !== null ||
props.activePendingInteraction !== null
props.activePendingInteraction !== null ||
props.rollbackStatus?.state === "pending" ||
props.rollbackStatus?.state === "recovering" ||
props.rollbackStatus?.state === "manual-recovery"
}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
Expand Down
44 changes: 43 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
} from "@t3tools/client-runtime/markdown-images";
import type { RollbackTarget } from "@t3tools/client-runtime/rollback";
import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation";
import {
renderCodexFileCitationsAsMarkdown,
Expand Down Expand Up @@ -230,6 +231,10 @@ export interface ThreadFeedProps {
readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
readonly rollbackTargets: ReadonlyMap<string, RollbackTarget>;
readonly rollbackTargetIdle: boolean;
readonly rollbackCommandPending: boolean;
readonly onRevertMessage: (target: RollbackTarget) => void;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: {
readonly loading: boolean;
Expand Down Expand Up @@ -1449,7 +1454,16 @@ function useMarkdownStyles(

function renderFeedEntry(
info: { item: ThreadFeedEntry; index: number },
props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & {
props: Pick<
ThreadFeedProps,
| "environmentId"
| "onUseArtifactTemplate"
| "skills"
| "rollbackTargets"
| "rollbackTargetIdle"
| "rollbackCommandPending"
| "onRevertMessage"
> & {
readonly copiedRowId: string | null;
readonly expandedWorkRows: Record<string, boolean>;
readonly terminalAssistantMessageIds: ReadonlySet<string>;
Expand Down Expand Up @@ -1591,6 +1605,26 @@ function renderFeedEntry(
<Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
{timestampLabel}
</Text>
{props.rollbackTargetIdle && props.rollbackTargets.has(message.id) ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Revert to this message"
disabled={props.rollbackCommandPending}
hitSlop={6}
onPress={() => {
const target = props.rollbackTargets.get(message.id);
if (target) props.onRevertMessage(target);
}}
className="size-11 items-center justify-center rounded-full disabled:opacity-50"
>
<SymbolView
name="arrow.uturn.backward"
size={14}
tintColor={iconSubtleColor}
type="monochrome"
/>
</Pressable>
) : null}
{message.text.trim().length > 0 ? (
<CopyTextButton
accessibilityLabel="Copy message"
Expand Down Expand Up @@ -2639,6 +2673,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
userBubbleMaxWidth,
skills: props.skills,
onUseArtifactTemplate: props.onUseArtifactTemplate,
rollbackTargets: props.rollbackTargets,
rollbackTargetIdle: props.rollbackTargetIdle,
rollbackCommandPending: props.rollbackCommandPending,
onRevertMessage: props.onRevertMessage,
})}
</ThreadMediaVisibility>
</Animated.View>
Expand All @@ -2665,6 +2703,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.environmentId,
props.onUseArtifactTemplate,
props.skills,
props.rollbackTargets,
props.rollbackTargetIdle,
props.rollbackCommandPending,
props.onRevertMessage,
renderMarkdownImage,
],
);
Expand Down
Loading
Loading