Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
import { ThreadHandoffSheet } from "./features/threads/ThreadHandoffSheet";
import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen";
Expand Down Expand Up @@ -549,6 +550,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"SettingsLegal",
"SettingsSheet",
"ThreadAgents",
"ThreadHandoff",
"ThreadQueue",
"ThreadReviewComment",
"ThreadDevicePreview",
Expand Down Expand Up @@ -859,6 +861,15 @@ const RootStackConfig = createWorkspaceStackNavigator({
sheetGrabberVisible: true,
},
}),
ThreadHandoff: createNativeStackScreen({
screen: ThreadHandoffSheet,
options: {
...FORM_SHEET_PRESENTATION_OPTIONS,
headerShown: false,
sheetAllowedDetents: [0.5, 0.9],
sheetGrabberVisible: true,
},
}),
GitOverview: createNativeStackScreen({
screen: GitOverviewSheet,
linking: `${THREAD_LINKING_PREFIX}/git`,
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ export function HomeScreen(props: HomeScreenProps) {
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
titleRegenerationEnvironmentIds,
handoffEnvironmentIds,
} = listEnvironments;
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
Expand Down Expand Up @@ -758,6 +759,7 @@ export function HomeScreen(props: HomeScreenProps) {
onRenameThread={handleRenameThread}
onRegenerateThreadTitle={handleRegenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
handoffSupported={handoffEnvironmentIds.has(thread.environmentId)}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={handleSettleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
Expand Down Expand Up @@ -821,6 +823,7 @@ export function HomeScreen(props: HomeScreenProps) {
snoozeEnvironmentIds,
threadSearchMatchByKey,
titleRegenerationEnvironmentIds,
handoffEnvironmentIds,
toggleSettledShelf,
toggleSnoozedShelf,
toggleWorkingShelf,
Expand Down
18 changes: 16 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Dialog from "../../components/ui/Dialog";
import { useAtomValue } from "@effect/atom-react";
import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities";
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
import { ThreadHandoffCard, threadHandoffClosesComposer } from "./ThreadHandoffCard";
import { useNavigation } from "@react-navigation/native";
import type { WorktreeSetupCardProps } from "./worktree-setup-card";
import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
Expand Down Expand Up @@ -553,7 +554,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
useState<RuntimeRequestId | null>(null);
const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
const composerSlotHidden =
activeUserInputRequestId !== null || props.creationState?.kind === "failed";
activeUserInputRequestId !== null ||
props.creationState?.kind === "failed" ||
threadHandoffClosesComposer(props.selectedThread);
const [usageLimitsPanel, setUsageLimitsPanel] = useState<{
readonly key: string;
readonly threadKey: string;
Expand Down Expand Up @@ -1201,6 +1204,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
</Animated.View>
) : null}
<View
// With the composer closed, the card pads the home indicator itself.
style={
threadHandoffClosesComposer(props.selectedThread)
? { paddingBottom: composerBottomInset }
: undefined
}
>
<ThreadHandoffCard thread={props.selectedThread} />
</View>
<UsageLimitRecoveryCard
key={props.selectedThread.latestRun?.runId}
thread={props.selectedThread}
Expand Down Expand Up @@ -1296,7 +1309,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread

{/*
* Hidden (not unmounted) while a user-input request owns the composer slot, so
* composer drafts and editor state survive.
* composer drafts and editor state survive. A thread that moved away continues on
* the other environment, so its card owns the slot too.
*/}
<View style={composerSlotHidden ? { display: "none" } : undefined}>
{isProviderSubagent ? (
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/threads/ThreadGitControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export type ThreadGitMenuProps = {
readonly onOpenFilesInspector?: () => void;
readonly onOpenGitInspector?: () => void;
readonly onMergeBack?: () => void;
/** Present only when the thread can move to a linked environment. */
readonly onContinueOn?: () => void;
readonly onPull: () => Promise<void>;
readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>;
};
Expand Down Expand Up @@ -392,6 +394,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
},
]
: []),
...(props.onContinueOn
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
? [
{
description: "Move this thread and its work to a linked environment",
icon: { name: "arrow.up.right", type: "sfSymbol" as const },
label: "Continue on…",
onPress: props.onContinueOn,
type: "action" as const,
},
]
: []),
{
description: "Commit, files, branches",
icon: { name: "ellipsis", type: "sfSymbol" },
Expand Down Expand Up @@ -422,6 +435,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
props.canOpenTerminal,
props.canOperateTerminal,
props.gitStatus,
props.onContinueOn,
props.onMergeBack,
props.onOpenNewTerminal,
props.onOpenTerminal,
Expand Down Expand Up @@ -587,6 +601,17 @@ function threadGitMenuDefinition(
subtitle: "Turn diffs and worktree changes",
onPress: model.openReview,
},
...(props.onContinueOn
? [
{
id: "git-continue-on",
title: "Continue on…",
icon: "arrow.up.right",
subtitle: "Move this thread and its work to a linked environment",
onPress: props.onContinueOn,
},
]
: []),
{
id: "git-more",
title: "More",
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/src/features/threads/ThreadHandoffCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { threadHandoffNotice } from "@t3tools/client-runtime/state/peerLinks";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { useNavigation } from "@react-navigation/native";
import { useState } from "react";
import { View } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
import { cn } from "../../lib/cn";
import { useEnvironments } from "../../state/environments";
import { threadHandoffEnvironment } from "../../state/threadHandoff";
import { useAtomCommand } from "../../state/use-atom-command";
import { RequestActionButton } from "./RequestActionButton";

/** Whether the composer is closed because the thread is moving or has moved away. */
export function threadHandoffClosesComposer(
thread: Pick<EnvironmentThreadShell, "handoff">,
): boolean {
return thread.handoff?.state === "departing" || thread.handoff?.state === "departed";
}

/**
* A thread's move to a linked environment: waiting for its turn to end (with
* Keep it here), under way, moved (with a link there when this app is
* connected to it), or failed.
*/
export function ThreadHandoffCard({ thread }: { thread: EnvironmentThreadShell }) {
const navigation = useNavigation();
const cancel = useAtomCommand(threadHandoffEnvironment.cancel, {
label: "cancel thread move",
reportFailure: false,
});
const [cancelling, setCancelling] = useState(false);
const [error, setError] = useState<string | null>(null);
const { presentationById } = useEnvironments();
const handoff = thread.handoff;
const notice = threadHandoffNotice(handoff);
if (handoff === null || notice === null) return null;

const keepHere = async () => {
setCancelling(true);
setError(null);
const result = await cancel({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
setCancelling(false);
if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result)));
};

return (
<View className="mx-3 mb-2 gap-2 rounded-xl border border-border-subtle bg-screen p-3">
<Text
className={cn(
"text-sm font-t3-bold",
notice.tone === "error" ? "text-danger-foreground" : "text-foreground",
)}
>
{notice.title}
</Text>
{notice.detail !== null ? (
<Text className="text-sm text-foreground-secondary">{notice.detail}</Text>
) : null}
{handoff.state === "pending" ? (
<View className="flex-row">
<RequestActionButton
label={cancelling ? "Cancelling…" : "Keep it here"}
tone="secondary"
disabled={cancelling}
onPress={() => void keepHere()}
/>
</View>
) : handoff.state === "departed" &&
presentationById.get(handoff.environmentId)?.connection.phase === "connected" ? (
<View className="flex-row">
<RequestActionButton
label={`Open on ${handoff.label}`}
tone="secondary"
onPress={() =>
navigation.navigate("Thread", {
environmentId: handoff.environmentId,
threadId: handoff.threadId,
})
}
/>
</View>
) : null}
{error !== null ? (
<Text accessibilityRole="alert" className="text-sm text-danger-foreground">
{error}
</Text>
) : null}
</View>
);
}
Loading
Loading