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
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
if (inFlightThreadIdsRef.current.has(threadKey)) return;
inFlightThreadIdsRef.current.add(threadKey);
try {
const messageId = await onSendMessage();
if (messageId === null) {
const sentMessageId = await onSendMessage();
if (sentMessageId === null) {
return;
}
// Sending a prompt starts agent work: arm the lock-screen card while the
Expand Down
140 changes: 139 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
import {
formatCodexGoalDescription,
formatCodexGoalError,
formatCodexGoalStatus,
formatCodexGoalUsage,
parseCodexGoalCommand,
type EnvironmentThreadStatus,
} from "@t3tools/client-runtime/state/threads";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import type { LegendListRef } from "@legendapp/list/react-native";
import { HeaderHeightContext } from "@react-navigation/elements";
Expand Down Expand Up @@ -30,6 +41,7 @@ import {
import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
import {
AppState,
Alert,
Keyboard,
Platform,
useWindowDimensions,
Expand All @@ -52,6 +64,9 @@ import Animated, {
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { ControlPill } from "../../components/ControlPill";
import { AppText as Text } from "../../components/AppText";
import { threadEnvironment, useCodexGoal } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
Expand Down Expand Up @@ -258,11 +273,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const listRef = useRef<LegendListRef>(null);
const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
const selectedThreadKeyRef = useRef(selectedThreadKey);
const draftMessageRef = useRef(props.draftMessage);
const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null);
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
const getCodexGoal = useAtomCommand(threadEnvironment.getCodexGoal, { reportFailure: false });
const setCodexGoal = useAtomCommand(threadEnvironment.setCodexGoal, { reportFailure: false });
const clearCodexGoal = useAtomCommand(threadEnvironment.clearCodexGoal, {
reportFailure: false,
});
// Android keys the safe-area padding on keyboard visibility (#5988): the
// back gesture closes the keyboard while the editor stays focused, and a
// focus-keyed inset would leave the toolbar under the gesture bar. iOS must
Expand Down Expand Up @@ -446,6 +467,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const isSplitLayout = layoutVariant === "split";
const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
const selectedProvider = props.serverConfig?.providers.find(
(provider) => provider.instanceId === selectedInstanceId,
);
const hasActiveCodexGoalSession =
selectedProvider?.driver === "codex" &&
props.selectedThread.session !== null &&
props.selectedThread.session.status !== "stopped";
const codexGoal = useCodexGoal(
hasActiveCodexGoalSession ? props.environmentId : null,
hasActiveCodexGoalSession ? props.selectedThread.id : null,
);
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
const selectedProviderSkills = useMemo(
() =>
Expand All @@ -458,6 +490,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
selectedThreadKeyRef.current = selectedThreadKey;
}, [selectedThreadKey]);

useLayoutEffect(() => {
draftMessageRef.current = props.draftMessage;
}, [props.draftMessage]);

useEffect(() => {
setAnchorMessageId(null);
setSubmittedMessageId(null);
Expand Down Expand Up @@ -521,6 +557,85 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
]);

const handleSendMessage = useCallback(async () => {
const draftGoalCommand =
props.draftAttachments.length === 0 ? parseCodexGoalCommand(props.draftMessage) : null;
if (draftGoalCommand !== null && selectedProvider === undefined) {
Alert.alert(
"Provider still loading",
"Wait for the thread's provider to load before running a Goal command.",
);
return null;
}
const goalCommand = selectedProvider?.driver === "codex" ? draftGoalCommand : null;
if (goalCommand !== null) {
if (goalCommand.action === "invalid") {
Alert.alert("Invalid Goal command", goalCommand.message);
return null;
}
if (props.selectedThread.session === null) {
Alert.alert(
"Start the Codex thread first",
"Send a message before managing its native Goal.",
);
return null;
}
Comment thread
cursor[bot] marked this conversation as resolved.
const target = {
environmentId: props.environmentId,
input: { threadId: props.selectedThread.id },
};
const submittedDraft = props.draftMessage;
const submittedThreadKey = selectedThreadKey;
const stillOnSubmittedThread = () => selectedThreadKeyRef.current === submittedThreadKey;
const clearSubmittedGoalCommandDraft = () => {
if (!stillOnSubmittedThread() || draftMessageRef.current !== submittedDraft) return;
props.onChangeDraftMessage("");
};
if (goalCommand.action === "status") {
const result = await getCodexGoal(target);
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result) && stillOnSubmittedThread()) {
Alert.alert(
"Codex Goal operation failed",
formatCodexGoalError(squashAtomCommandFailure(result)),
);
}
return null;
}
clearSubmittedGoalCommandDraft();
if (!stillOnSubmittedThread()) return null;
Alert.alert(
result.value === null
? "No active Codex Goal"
: `Goal ${formatCodexGoalStatus(result.value.status)}`,
result.value === null ? undefined : formatCodexGoalDescription(result.value),
);
return null;
}
const result =
goalCommand.action === "clear"
? await clearCodexGoal(target)
: await setCodexGoal({
environmentId: props.environmentId,
input: {
threadId: props.selectedThread.id,
...(goalCommand.objective === undefined
? {}
: { objective: goalCommand.objective }),
...(goalCommand.status === undefined ? {} : { status: goalCommand.status }),
},
});
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result) && stillOnSubmittedThread()) {
Alert.alert(
"Codex Goal operation failed",
formatCodexGoalError(squashAtomCommandFailure(result)),
);
}
return null;
}
clearSubmittedGoalCommandDraft();
return null;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
const targetThreadKey = selectedThreadKey;
const hasUserMessage = selectedThreadFeed.some(
(entry) => entry.type === "message" && entry.message.role === "user",
Expand All @@ -544,11 +659,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
return messageId;
}, [
anchorMessageId,
clearCodexGoal,
getCodexGoal,
props.onSendMessage,
props.draftAttachments,
props.draftMessage,
props.environmentId,
props.onChangeDraftMessage,
props.selectedThread.id,
props.selectedThread.latestTurn,
props.selectedThread.session,
props.selectedThreadQueueCount,
selectedThreadFeed,
selectedThreadKey,
selectedProvider?.driver,
setCodexGoal,
]);

const collapseComposer = useCallback(() => {
Expand Down Expand Up @@ -739,6 +864,19 @@ 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. */}
<View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}>
{codexGoal !== null ? (
<View className="mx-3 mb-2 rounded-xl border border-blue-500/20 bg-blue-500/10 px-3 py-2">
<Text className="text-xs font-t3-bold text-foreground">
Goal {formatCodexGoalStatus(codexGoal.status)}
</Text>
<Text className="text-xs text-foreground-muted" numberOfLines={2}>
{codexGoal.objective}
</Text>
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{formatCodexGoalUsage(codexGoal)}
</Text>
</View>
) : null}
<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/src/state/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type EnvironmentThreadState,
createThreadEnvironmentAtoms,
} from "@t3tools/client-runtime/state/threads";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { CodexGoal, EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";

Expand All @@ -28,6 +28,21 @@ export const environmentThreadShells = createEnvironmentThreadShellAtoms({
const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe(
Atom.withLabel("mobile-environment-thread:empty"),
);
const EMPTY_CODEX_GOAL_ATOM = Atom.make(AsyncResult.success<CodexGoal | null>(null)).pipe(
Atom.withLabel("mobile-codex-goal:empty"),
);

export function useCodexGoal(
environmentId: EnvironmentId | null,
threadId: ThreadId | null,
): CodexGoal | null {
const result = useAtomValue(
environmentId !== null && threadId !== null
? threadEnvironment.codexGoal({ environmentId, input: { threadId } })
: EMPTY_CODEX_GOAL_ATOM,
);
return Option.getOrNull(AsyncResult.value(result));
}

export function useEnvironmentThread(
environmentId: EnvironmentId | null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ const startupDependencies = Layer.mergeAll(
getInstanceInfo: () => Effect.die("unused"),
rollbackConversation: () => Effect.die("unused"),
uploadFeedback: () => Effect.die("unused"),
getCodexGoal: () => Effect.die("unused"),
setCodexGoal: () => Effect.die("unused"),
clearCodexGoal: () => Effect.die("unused"),
streamEvents: Stream.empty,
}),
);
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
[WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
[WS_METHODS.codexGoalGet]: AuthOrchestrationReadScope,
[WS_METHODS.codexGoalSet]: AuthOrchestrationOperateScope,
[WS_METHODS.codexGoalClear]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeCodexGoal]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
[WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ function createProviderServiceHarness(
}),
rollbackConversation,
uploadFeedback: () => unsupported(),
getCodexGoal: () => unsupported(),
setCodexGoal: () => unsupported(),
clearCodexGoal: () => unsupported(),
get streamEvents() {
return Stream.fromPubSub(runtimeEventPubSub);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,9 @@ describe("ProviderCommandReactor", () => {
},
rollbackConversation: () => unsupported(),
uploadFeedback: () => unsupported(),
getCodexGoal: () => unsupported(),
setCodexGoal: () => unsupported(),
clearCodexGoal: () => unsupported(),
get streamEvents() {
return Stream.fromPubSub(runtimeEventPubSub);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ function createProviderServiceHarness() {
},
rollbackConversation: () => unsupported(),
uploadFeedback: () => unsupported(),
getCodexGoal: () => unsupported(),
setCodexGoal: () => unsupported(),
clearCodexGoal: () => unsupported(),
get streamEvents() {
return Stream.fromPubSub(runtimeEventPubSub);
},
Expand Down
Loading
Loading