Skip to content
Closed
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
@@ -1,3 +1,4 @@
import { ThreadHandoffSheet } from "./features/threads/ThreadHandoffSheet";
import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen";
import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen";
import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen";
Expand Down Expand Up @@ -550,6 +551,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"NewTaskSheet",
"SettingsLegal",
"SettingsSheet",
"ThreadHandoff",
"ThreadAgents",
"ThreadPullRequests",
"ThreadQueue",
Expand Down Expand Up @@ -855,6 +857,15 @@ const RootStackConfig = createWorkspaceStackNavigator({
sheetGrabberVisible: true,
},
}),
ThreadHandoff: createNativeStackScreen({
screen: ThreadHandoffSheet,
options: {
...FORM_SHEET_PRESENTATION_OPTIONS,
headerShown: false,
sheetAllowedDetents: [0.65, 0.95],
sheetGrabberVisible: true,
},
}),
ThreadAgents: createNativeStackScreen({
screen: ThreadAgentsSheet,
options: {
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import {
threadSearchMatchKey,
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
import {
type EnvironmentId,
type SidebarProjectGroupingMode,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { useFocusEffect } from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { use, useEffect, useRef, useState, type ComponentProps } from "react";
import {
Platform,
Expand Down Expand Up @@ -250,6 +254,10 @@ function renderHomeScrollView(props: ScrollViewProps) {
}

export function HomeScreen(props: HomeScreenProps) {
const navigation = useNavigation();
const handleContinueOn = (ref: ScopedThreadRef) => {
navigation.navigate("ThreadHandoff", ref);
};
const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
const workspace = useWorkspaceTabs();
const [initialScrollOffset] = useState(() => workspace?.getScrollOffset("threads") ?? 0);
Expand Down Expand Up @@ -631,6 +639,7 @@ export function HomeScreen(props: HomeScreenProps) {
selected={scopedThreadKey(thread.environmentId, thread.id) === selectedThreadKey}
fullSwipeWidth={fullSwipeWidth}
onNewThreadOnBranch={props.onNewThreadOnBranch}
onContinueOn={handleContinueOn}
thread={thread}
variant={item.item.variant}
hasQueuedMessages={item.hasQueuedMessages}
Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type {
EnvironmentProject,
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
import {
EnvironmentId,
ThreadId,
type SidebarProjectGroupingMode,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
import { useFocusEffect } from "@react-navigation/native";
import {
Expand Down Expand Up @@ -416,6 +421,10 @@ function AdaptiveWorkspaceLayoutContent(
});
};

const handleContinueOn = (ref: ScopedThreadRef) => {
navigation.navigate("ThreadHandoff", ref);
};

const handleOpenThreadPullRequests = useOpenThreadPullRequests();

const handleNewThreadOnBranch = (thread: EnvironmentThreadShell) => {
Expand Down Expand Up @@ -683,6 +692,7 @@ function AdaptiveWorkspaceLayoutContent(
onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
onNewThreadInProject={handleNewThreadInProject}
onNewThreadOnBranch={handleNewThreadOnBranch}
onContinueOn={handleContinueOn}
onSelectThread={handleSelectThread}
onOpenThreadPullRequests={handleOpenThreadPullRequests}
onSearchQueryChange={setPrimarySidebarSearchQuery}
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ThreadHandoffCard } from "./ThreadHandoffCard";
import { threadHandoffIsReadOnly } from "@t3tools/client-runtime/state/threads";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { AsyncResult } from "effect/reactivity";
import * as Dialog from "../../components/ui/Dialog";
Expand Down Expand Up @@ -612,7 +614,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
useState<RuntimeRequestId | null>(null);
const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
const composerSlotHidden =
activeUserInputRequestId !== null || props.creationState?.kind === "failed";
activeUserInputRequestId !== null ||
props.creationState?.kind === "failed" ||
threadHandoffIsReadOnly(props.selectedThread);
const [usageLimitsPanel, setUsageLimitsPanel] = useState<{
readonly key: string;
readonly threadKey: string;
Expand Down Expand Up @@ -1284,6 +1288,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
) : null}
</View>

<ThreadHandoffCard thread={props.selectedThread} />
{/*
* Hidden (not unmounted) while a user-input request owns the composer slot, so
* composer drafts and editor state survive.
Expand Down
34 changes: 32 additions & 2 deletions apps/mobile/src/features/threads/ThreadGitControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string {
type HeaderItem = Record<string, unknown>;
type HeaderItems = HeaderItem[];
type ThreadGitHeaderActionItems = {
readonly handoff: HeaderItem | null;
readonly terminal: HeaderItem;
readonly files: HeaderItem;
readonly git: HeaderItem;
Expand All @@ -96,6 +97,7 @@ export type ThreadGitMenuProps = {
};

type ThreadGitControlsProps = ThreadGitMenuProps & {
readonly onContinueOn?: () => void;
readonly auxiliaryPaneControl?: {
readonly accessibilityLabel: string;
readonly onPress: () => void;
Expand Down Expand Up @@ -264,6 +266,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
const model = useThreadGitControlModel(props);

return {
handoff:
props.onContinueOn !== undefined
? {
type: "button" as const,
identifier: "thread-continue-on",
accessibilityLabel: "Continue on…",
label: "Continue on…",
icon: { name: "arrow.right", type: "sfSymbol" as const },
onPress: props.onContinueOn,
}
: null,
terminal: {
accessibilityLabel: "Open terminal",
disabled: !props.canOpenTerminal,
Expand Down Expand Up @@ -397,12 +410,22 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit

export function useThreadGitRightHeaderItems(props: ThreadGitControlsProps): HeaderItems {
const actionItems = useThreadGitHeaderActionItems(props);
return [actionItems.git, actionItems.files, actionItems.terminal] as HeaderItems;
return [
...(actionItems.handoff ? [actionItems.handoff] : []),
actionItems.git,
actionItems.files,
actionItems.terminal,
] as HeaderItems;
}

export function useThreadGitCenterHeaderItems(props: ThreadGitControlsProps): HeaderItems {
const actionItems = useThreadGitHeaderActionItems(props);
return [actionItems.files, actionItems.git, actionItems.terminal] as HeaderItems;
return [
...(actionItems.handoff ? [actionItems.handoff] : []),
actionItems.files,
actionItems.git,
actionItems.terminal,
] as HeaderItems;
}

export function ThreadGitControls(props: ThreadGitControlsProps) {
Expand All @@ -415,6 +438,13 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {

return (
<Toolbar placement="right">
{props.onContinueOn !== undefined ? (
<Toolbar.Button
accessibilityLabel="Continue on…"
icon="arrow.right"
onPress={props.onContinueOn}
/>
) : null}
{showActionControls && props.auxiliaryPaneControl ? (
<Toolbar.Button
accessibilityLabel={props.auxiliaryPaneControl.accessibilityLabel}
Expand Down
160 changes: 160 additions & 0 deletions apps/mobile/src/features/threads/ThreadHandoffCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { useAtomValue } from "@effect/atom-react";
import { useNavigation } from "@react-navigation/native";
import { threadHandoffNotice } from "@t3tools/client-runtime/state/threads";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { AppText as Text } from "../../components/ui/AppText";
import { Button } from "../../components/ui/Button";
import * as Dialog from "../../components/ui/Dialog";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentPresentations } from "../../state/presentation";
import { environmentThreadShells } from "../../state/threads";
import { threadHandoffEnvironment } from "../../state/threadHandoff";

export function ThreadHandoffCard({ thread }: { thread: EnvironmentThreadShell }) {
return thread.handoff ? (
<Notice
key={`${thread.environmentId}:${thread.id}:${thread.handoff.handoffId}`}
thread={thread}
/>
) : null;
}
function Notice({ thread }: { thread: EnvironmentThreadShell }) {
const handoff = thread.handoff!;
const ref = { environmentId: thread.environmentId, threadId: thread.id };
const notice = threadHandoffNotice(handoff)!;
const canCancel = useAtomValue(
threadHandoffEnvironment.cancel.permissionAtom(thread.environmentId),
);
const canRetry = useAtomValue(
threadHandoffEnvironment.start.permissionAtom(thread.environmentId),
);
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const sourceConnected = presentations.get(thread.environmentId)?.connection.phase === "connected";
const navigation = useNavigation();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const working = useRef(false);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const current = () => {
const live = appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref))?.handoff;
return alive.current && live?.handoffId === handoff.handoffId && live.state === handoff.state;
};
const act = async (action: "cancel" | "retry" | "abandon") => {
if (!current() || working.current) return;
const command =
action === "retry" ? threadHandoffEnvironment.start : threadHandoffEnvironment.cancel;
if (!appAtomRegistry.get(command.permissionAtom(thread.environmentId))) return;
working.current = true;
setBusy(true);
setError(null);
const run = async () => {
const result =
action === "retry"
? await threadHandoffEnvironment.start.run(appAtomRegistry, {
environmentId: thread.environmentId,
input: {
threadId: thread.id,
environmentId: handoff.environmentId,
projectId: handoff.projectId,
handoffId: handoff.handoffId,
isCurrent: current,
},
})
: await threadHandoffEnvironment.cancel.run(appAtomRegistry, {
environmentId: thread.environmentId,
input: {
threadId: thread.id,
handoffId: handoff.handoffId,
isCurrent: current,
...(action === "abandon" ? { abandon: true } : {}),
},
});
if (current() && result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const failure = squashAtomCommandFailure(result);
setError(
failure instanceof Error ? failure.message : "Could not update the move. Try again.",
);
}
};
await run().finally(() => {
if (alive.current) {
working.current = false;
setBusy(false);
}
});
};
const abandon = () =>
Dialog.confirm({
title: "Continue here?",
message:
"The move outcome is unknown. The destination may already have a copy or finish accepting it later. Continue working here anyway?",
confirmText: "Continue here",
destructive: true,
onConfirm: () => {
if (current()) void act("abandon");
},
});
return (
<View className="gap-3 rounded-2xl border border-border bg-card p-4">
<Text className="font-t3-medium">{notice.title}</Text>
<Text className="text-sm text-foreground-muted">{notice.detail}</Text>
{handoff.state === "pending" ? (
<Button
label="Cancel move"
disabled={!canCancel || !sourceConnected || busy}
onPress={() => void act("cancel")}
/>
) : null}
{handoff.state === "departing" ? (
<>
<Button
label="Retry move"
disabled={!canRetry || !sourceConnected || busy}
onPress={() => void act("retry")}
/>
<Button
label="Continue here…"
tone="text"
disabled={!canCancel || !sourceConnected || busy}
onPress={abandon}
/>
</>
) : null}
{handoff.state === "departed" &&
presentations.get(handoff.environmentId)?.connection.phase === "connected" ? (
<Button
label={`Open on ${handoff.label}`}
onPress={() => {
if (
!current() ||
appAtomRegistry.get(environmentPresentations.presentationAtom(handoff.environmentId))
?.connection.phase !== "connected"
)
return;
navigation.navigate("Thread", {
environmentId: handoff.environmentId,
threadId: handoff.threadId,
});
}}
/>
) : null}
{error ? (
<Text accessibilityRole="alert" className="text-sm text-danger">
{error}
</Text>
) : null}
</View>
);
}
Loading
Loading