Skip to content
Draft
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 @@ -441,8 +441,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
118 changes: 112 additions & 6 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import {
formatCodexGoalDescription,
formatCodexGoalError,
formatCodexGoalStatus,
formatCodexGoalUsage,
parseCodexGoalCommand,
toCodexGoalSetInput,
} from "@t3tools/client-runtime/state/threadCommands";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { AppText as Text } from "../../components/AppText";
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
Expand Down Expand Up @@ -33,6 +46,7 @@ import {
useState,
} from "react";
import {
Alert,
AppState,
Keyboard,
Platform,
Expand Down Expand Up @@ -62,7 +76,7 @@ import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerAttachment } from "../../lib/composerImages";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { threadEnvironment } from "../../state/threads";
import { threadEnvironment, useCodexGoal } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import type {
PendingApproval,
Expand Down Expand Up @@ -275,7 +289,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
const composerEditorRef = useRef<ComposerEditorHandle>(null);
const draftMessageRef = useRef(props.draftMessage);
draftMessageRef.current = props.draftMessage;
useLayoutEffect(() => {
draftMessageRef.current = props.draftMessage;
}, [props.draftMessage]);
const composerOverlayRef = useRef<View>(null);
const listRef = useRef<LegendListRef>(null);
const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
Expand All @@ -293,6 +309,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
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 @@ -408,7 +429,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
composerOverlayRef,
Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
-nativeInsetOvercount,
Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0,
);
// The expanded questionnaire is an absolute overlay on iOS, so it never
// changes the measured overlay height (that constancy is what keeps the
Expand Down Expand Up @@ -543,6 +563,14 @@ 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 codexGoal = useCodexGoal(
selectedProvider?.driver === "codex" ? props.environmentId : null,
selectedProvider?.driver === "codex" ? props.selectedThread.id : null,
selectedProvider?.driver === "codex" ? selectedInstanceId : null,
);
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
const selectedProviderSkills = useMemo(() => {
const provider = props.serverConfig?.providers.find(
Expand Down Expand Up @@ -655,6 +683,69 @@ 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 provider list to finish loading.");
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;
}
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: toCodexGoalSetInput(props.selectedThread.id, goalCommand),
});
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result) && stillOnSubmittedThread()) {
Alert.alert(
"Codex Goal operation failed",
formatCodexGoalError(squashAtomCommandFailure(result)),
);
}
return null;
}
clearSubmittedGoalCommandDraft();
return null;
}
const targetThreadKey = selectedThreadKey;
const hasUserMessage = selectedThreadFeed.some(
(entry) => entry.type === "message" && entry.message.role === "user",
Expand All @@ -678,9 +769,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
return messageId;
}, [
anchorMessageId,
props.onSendMessage,
props.selectedThread.latestRun,
props.selectedThreadQueueCount,
clearCodexGoal,
getCodexGoal,
setCodexGoal,
selectedProvider,
props,
selectedThreadFeed,
selectedThreadKey,
]);
Expand Down Expand Up @@ -876,6 +969,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
18 changes: 17 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, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";

Expand All @@ -28,6 +28,22 @@ 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,
providerInstanceId: ProviderInstanceId | null,
): CodexGoal | null {
const result = useAtomValue(
environmentId !== null && threadId !== null && providerInstanceId !== null
? threadEnvironment.codexGoal({ environmentId, input: { threadId, providerInstanceId } })
: EMPTY_CODEX_GOAL_ATOM,
);
return Option.getOrNull(AsyncResult.value(result));
}

export function useEnvironmentThread(
environmentId: EnvironmentId | null,
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 @@ -113,6 +113,10 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.assetsPersistChatAttachments]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
[WS_METHODS.codexGoalGet]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeCodexGoal]: AuthOrchestrationReadScope,
[WS_METHODS.codexGoalSet]: AuthOrchestrationOperateScope,
[WS_METHODS.codexGoalClear]: AuthOrchestrationOperateScope,
[WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
Expand Down
Loading
Loading