Skip to content
Merged
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
28 changes: 16 additions & 12 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { StatusBar, View } from "react-native";
import { StatusBar } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
Expand All @@ -23,6 +23,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal";
import { shouldHandleAppLink } from "./lib/appLinking";
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";

import "../global.css";

Expand Down Expand Up @@ -76,22 +78,24 @@ function AppContent() {
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
{/* The navigation theme drives the NATIVE header appearance: native-stack
<VoiceInputProvider>
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
{/* The navigation theme drives the NATIVE header appearance: native-stack
forwards `dark` as the nav bar's overrideUserInterfaceStyle. Without
this, React Navigation defaults to its light theme and every native
header (glass buttons, title, materials) is forced light even when
the system is in dark mode. */}
<View style={{ flex: 1 }}>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
<ThreadArrangementHost />
</View>
{/* Anchored-menu overlays render here — in-window, so the
<GlobalVoiceInputControl>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</IncomingShareProvider>
<ConfirmDialogHost />
<ThreadArrangementHost />
</GlobalVoiceInputControl>
{/* Anchored-menu overlays render here — in-window, so the
keyboard stays up while a dropdown is open. */}
<OverlayPortalHost />
<OverlayPortalHost />
</VoiceInputProvider>
</SafeAreaProvider>
</KeyboardProvider>
</GestureHandlerRootView>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
Expand Down Expand Up @@ -63,6 +62,8 @@ import {
} from "./scheduledTaskDraft";
import { settingsTargetsForProject } from "./settings-environment-filter.logic";
import { useScheduledTaskEditor } from "./scheduled-task-editor";
import { scheduledTaskEditorSessionAtom } from "./scheduled-task-editor-state";
import { appAtomRegistry } from "../../state/atom-registry";
import {
formatNextScheduledTaskRun,
formatScheduledTaskInterval,
Expand Down Expand Up @@ -372,14 +373,21 @@ export function SettingsScheduledTaskEditRouteScreen() {
}

function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }) {
const { editor, setEditor, hasChanges, draftForEnvironment } = useScheduledTaskEditor();
const {
editor,
voiceOwnerKey,
readEditor,
setEditor,
startEditor,
hasChanges,
draftForEnvironment,
} = useScheduledTaskEditor();
const { availableTargets } = useSettingsEnvironmentFilter();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const voiceOwnerId = useId();
const ownerKey = editor ? `${voiceOwnerId}:${editor.environmentId}` : null;
const ownerKey = editor ? `${voiceOwnerKey}:${editor.environmentId}` : null;
const prompt = editor?.draft.prompt ?? "";
const [selectionState, setSelectionState] = useState<{
readonly ownerKey: string | null;
Expand All @@ -402,32 +410,40 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }
);
const voiceInput = useVoiceInputController({
ownerKey,
draftMessage: prompt,
label: editor?.draft.title.trim() || title,
subscribeToDraftChanges: (onChange) =>
appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange),
selection,
readDraftMessage: () => {
const current = readEditor();
return current?.environmentId === editor?.environmentId
? (current?.draft.prompt ?? null)
: null;
},
onChangeSelection: setSelection,
onChangeDraftMessage: setPrompt,
onChangeDraftMessage: (text) => {
if (readEditor()?.environmentId === editor?.environmentId) setPrompt(text);
},
disabled: saving,
});
const preventRemove = !saved && (hasChanges || saving || voiceInput.isBusy);
const preventRemove = !saved && (hasChanges || saving);
usePreventRemove(preventRemove, ({ data }) => {
if (saving) {
Alert.alert("Saving task", "Wait for the task to finish saving before leaving.");
return;
}
Alert.alert(
"Discard changes?",
voiceInput.isBusy
? "Your dictation and unsaved changes will be lost."
: "Your unsaved changes will be lost.",
[
{ text: "Keep editing", style: "cancel" },
{
text: "Discard changes",
style: "destructive",
onPress: () => navigation.dispatch(data.action),
Alert.alert("Discard changes?", "Your unsaved changes will be lost.", [
{ text: "Keep editing", style: "cancel" },
{
text: "Discard changes",
style: "destructive",
onPress: () => {
if (voiceInput.isBusy) voiceInput.cancel();
startEditor(null);
navigation.dispatch(data.action);
},
],
);
},
]);
});
useEffect(() => {
if (!saved) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { EnvironmentId } from "@t3tools/contracts";
import { afterEach, describe, expect, it } from "vite-plus/test";

import { appAtomRegistry } from "../../state/atom-registry";
import { createDraft } from "./scheduledTaskDraft";
import {
readScheduledTaskEditor,
scheduledTaskEditorSessionAtom,
startScheduledTaskEditor,
updateScheduledTaskEditor,
} from "./scheduled-task-editor-state";

afterEach(() => appAtomRegistry.set(scheduledTaskEditorSessionAtom, null));

describe("scheduled task voice draft", () => {
const editor = {
environmentId: EnvironmentId.make("environment"),
environmentLabel: "Environment",
draft: createDraft(null, null),
};

it("keeps the prompt outside the screen and retains its original change baseline", () => {
startScheduledTaskEditor(editor);
const original = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
const ownerKey = `scheduled-task:${original.id}`;
updateScheduledTaskEditor(
(current) =>
current && {
...current,
draft: { ...current.draft, prompt: "spoken prompt" },
},
null,
);

expect(readScheduledTaskEditor(ownerKey, null)?.draft.prompt).toBe("spoken prompt");
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.initial).toBe(editor);
});

it("rejects an old voice target when another task opens in the same environment", () => {
startScheduledTaskEditor(editor);
const original = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
startScheduledTaskEditor({ ...editor, draft: { ...editor.draft, prompt: "other task" } });

expect(readScheduledTaskEditor(`scheduled-task:${original.id}`, editor)).toBeNull();
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current?.draft.prompt).toBe(
"other task",
);
});

it("does not restore the default prompt after the editor is discarded", () => {
startScheduledTaskEditor(null);
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom)!;
expect(readScheduledTaskEditor(`scheduled-task:${session.id}`, editor)).toBeNull();
updateScheduledTaskEditor((current) => current, editor);
expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toBeNull();
});
});
55 changes: 55 additions & 0 deletions apps/mobile/src/features/settings/scheduled-task-editor-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";

import { appAtomRegistry } from "../../state/atom-registry";
import type { ScheduledTaskDraft } from "./scheduledTaskDraft";

export type ScheduledTaskEditor = {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
readonly draft: ScheduledTaskDraft;
};

type ScheduledTaskEditorSession = {
readonly id: number;
readonly initial: ScheduledTaskEditor | null;
readonly current: ScheduledTaskEditor | null;
};

export const scheduledTaskEditorSessionAtom = Atom.make<ScheduledTaskEditorSession | null>(
null,
).pipe(Atom.keepAlive);
let nextEditorId = 0;

export function startScheduledTaskEditor(next: ScheduledTaskEditor | null): void {
appAtomRegistry.set(scheduledTaskEditorSessionAtom, {
id: ++nextEditorId,
initial: next,
current: next,
});
}

export function updateScheduledTaskEditor(
update:
| ScheduledTaskEditor
| null
| ((current: ScheduledTaskEditor | null) => ScheduledTaskEditor | null),
defaultEditor: ScheduledTaskEditor | null,
): void {
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom);
const previous = session ? session.current : defaultEditor;
appAtomRegistry.set(scheduledTaskEditorSessionAtom, {
id: session?.id ?? 0,
initial: session ? session.initial : previous,
current: typeof update === "function" ? update(previous) : update,
});
}

export function readScheduledTaskEditor(
voiceOwnerKey: string,
defaultEditor: ScheduledTaskEditor | null,
): ScheduledTaskEditor | null {
const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom);
if (`scheduled-task:${session?.id ?? 0}` !== voiceOwnerKey) return null;
return session ? session.current : defaultEditor;
}
64 changes: 40 additions & 24 deletions apps/mobile/src/features/settings/scheduled-task-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentId } from "@t3tools/contracts";
import {
createContext,
use,
useMemo,
useCallback,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
Expand All @@ -20,14 +20,18 @@ import {
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
import { settingsTargetsForProject } from "./settings-environment-filter.logic";

type ScheduledTaskEditor = {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
readonly draft: ScheduledTaskDraft;
};
import {
scheduledTaskEditorSessionAtom,
startScheduledTaskEditor,
updateScheduledTaskEditor,
readScheduledTaskEditor,
type ScheduledTaskEditor,
} from "./scheduled-task-editor-state";

const ScheduledTaskEditorContext = createContext<{
readonly editor: ScheduledTaskEditor | null;
readonly voiceOwnerKey: string;
readonly readEditor: () => ScheduledTaskEditor | null;
readonly setEditor: Dispatch<SetStateAction<ScheduledTaskEditor | null>>;
readonly startEditor: (editor: ScheduledTaskEditor | null) => void;
readonly hasChanges: boolean;
Expand Down Expand Up @@ -76,12 +80,10 @@ export function ScheduledTaskEditorProvider({ children }: { readonly children: R
: null,
[defaultTarget, draftForEnvironment],
);
const [session, setSession] = useState<{
readonly initial: ScheduledTaskEditor | null;
readonly current: ScheduledTaskEditor | null;
} | null>(null);
const editor = session?.current ?? defaultEditor;
const initial = session?.initial ?? defaultEditor;
const session = useAtomValue(scheduledTaskEditorSessionAtom);
const voiceOwnerKey = `scheduled-task:${session?.id ?? 0}`;
const editor = session ? session.current : defaultEditor;
const initial = session ? session.initial : defaultEditor;
const hasChanges = Boolean(
initial &&
editor &&
Expand All @@ -90,23 +92,37 @@ export function ScheduledTaskEditorProvider({ children }: { readonly children: R
);
const setEditor = useCallback<Dispatch<SetStateAction<ScheduledTaskEditor | null>>>(
(update) => {
setSession((current) => {
const previous = current?.current ?? defaultEditor;
return {
initial: current?.initial ?? previous,
current: typeof update === "function" ? update(previous) : update,
};
});
updateScheduledTaskEditor(update, defaultEditor);
},
[defaultEditor],
);
const startEditor = useCallback((next: ScheduledTaskEditor | null) => {
setSession({ initial: next, current: next });
}, []);
const startEditor = startScheduledTaskEditor;
const resetEditor = useCallback(() => startEditor(defaultEditor), [defaultEditor, startEditor]);
const readEditor = useCallback(
() => readScheduledTaskEditor(voiceOwnerKey, defaultEditor),
[defaultEditor, voiceOwnerKey],
);
const value = useMemo(
() => ({ editor, setEditor, startEditor, hasChanges, resetEditor, draftForEnvironment }),
[editor, setEditor, startEditor, hasChanges, resetEditor, draftForEnvironment],
() => ({
editor,
voiceOwnerKey,
readEditor,
setEditor,
startEditor,
hasChanges,
resetEditor,
draftForEnvironment,
}),
[
editor,
voiceOwnerKey,
readEditor,
setEditor,
startEditor,
hasChanges,
resetEditor,
draftForEnvironment,
],
);
return <ScheduledTaskEditorContext value={value}>{children}</ScheduledTaskEditorContext>;
}
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
captureComposerDraftInsertion,
countComposerDraftAttachmentsAfterSelection,
getComposerDraftSnapshot,
composerDraftsAtom,
mergeComposerDraftContent,
restoreComposerDraftSnapshot,
updateComposerDraftSettings,
Expand Down Expand Up @@ -473,7 +474,9 @@ export function NewTaskDraftScreen(props: {
});
const voiceInput = useVoiceInputController({
ownerKey: flow.draftKey,
draftMessage: flow.prompt,
label: selectedProject ? `New task in ${selectedProject.title}` : "New task",
readDraftMessage: () => (flow.draftKey ? getComposerDraftSnapshot(flow.draftKey).text : null),
subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange),
selection: composerMenu.selection,
disabled: isIncomingShareTransferPending || isImportingShare || flow.submitting,
onChangeDraftMessage: flow.setPrompt,
Expand Down
Loading
Loading