Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
61dfa49
feat(codex): add native Goal lifecycle controls
stekman08 Aug 22, 2026
fa95427
fix(codex): keep Goal operations thread scoped
stekman08 Aug 23, 2026
2348c57
fix(codex): gate Goal commands on session
stekman08 Aug 23, 2026
2a00a98
fix(clients): surface running Goal commands
stekman08 Aug 23, 2026
9311335
fix(server): bound Goal event buffering
stekman08 Aug 23, 2026
481a99c
fix(clients): defer Goal subscription until session
stekman08 Aug 24, 2026
8a6b44e
fix(clients): refresh Goal after session resume
stekman08 Aug 24, 2026
2252ce4
fix(clients): avoid unnecessary Goal refreshes
stekman08 Aug 25, 2026
14ba3f4
fix(codex): tighten goal request scoping
stekman08 Aug 25, 2026
05a15bd
fix(codex): keep goal status reads passive
stekman08 Aug 25, 2026
886158d
fix(codex): handle inactive Goal status
stekman08 Aug 25, 2026
001e8ae
fix(mobile): gate Goal commands by provider
stekman08 Aug 25, 2026
158c49d
fix(codex): handle goal reconnects and stopped sessions
stekman08 Aug 25, 2026
b7931bd
fix(codex): preserve goal recovery for stopped sessions
stekman08 Aug 25, 2026
75117b6
refactor(codex): share goal route resolution
stekman08 Aug 25, 2026
c02b51e
refactor(codex): reduce goal control duplication
stekman08 Aug 25, 2026
b27b447
fix(provider): serialize session recovery per thread
stekman08 Aug 25, 2026
53e078d
refactor(codex): simplify goal route plumbing
stekman08 Aug 25, 2026
d6c8590
refactor(codex): derive goal runtime input type
stekman08 Aug 25, 2026
cddacea
test(codex): cover native goal requests
stekman08 Aug 25, 2026
b0b74b4
fix(web): remove unreachable goal toast
stekman08 Aug 25, 2026
7848ca8
fix(codex): harden goal session recovery
stekman08 Aug 26, 2026
b68d236
fix(codex): serialize goal recovery with stops
stekman08 Aug 26, 2026
4b802f1
fix(clients): refresh goals when sessions become ready
stekman08 Aug 26, 2026
07f796d
fix(clients): keep Codex Goal updates live during turns
stekman08 Aug 26, 2026
7503f86
fix(clients): retain Goal subscription while starting
stekman08 Aug 26, 2026
69e1009
fix(clients): retain Goal state after provider errors
stekman08 Aug 26, 2026
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
134 changes: 133 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
import {
formatCodexGoalDescription,
formatCodexGoalError,
formatCodexGoalStatus,
formatCodexGoalUsage,
parseCodexGoalCommand,
toCodexGoalSetInput,
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 +42,7 @@ import {
import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
import {
AppState,
Alert,
Keyboard,
Platform,
useWindowDimensions,
Expand All @@ -52,6 +65,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 +274,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 +468,20 @@ 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,
hasActiveCodexGoalSession
? (props.selectedThread.session?.providerInstanceId ?? selectedInstanceId)
: null,
);
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
const selectedProviderSkills = useMemo(
() =>
Expand All @@ -458,6 +494,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 +561,75 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
]);

const handleSendMessage = useCallback(async () => {
const draftGoalCommand =
props.draftAttachments.length === 0 ? parseCodexGoalCommand(props.draftMessage) : 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 sessionWasStopped = props.selectedThread.session.status === "stopped";
const result = await getCodexGoal(target);
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result) && stillOnSubmittedThread()) {
Alert.alert(
sessionWasStopped ? "Wake the Codex thread first" : "Codex Goal operation failed",
sessionWasStopped
? "/goal status does not wake a stopped provider session."
: 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;
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 +653,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 +858,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
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 @@ -87,6 +87,10 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
[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 @@ -352,6 +352,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