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
160 changes: 109 additions & 51 deletions apps/mobile/src/components/ConfirmDialogHost.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { Modal, Pressable, View } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";

import { useThemeColor } from "../lib/useThemeColor";
import { cn } from "../lib/cn";
import { AppText } from "./AppText";
import { AppText, AppTextInput as TextInput } from "./AppText";

export type ConfirmDialogRequest = {
readonly title: string;
Expand All @@ -15,7 +16,24 @@ export type ConfirmDialogRequest = {
readonly onCancel?: () => void;
};

let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
export type PromptDialogRequest = {
readonly title: string;
readonly message?: string;
readonly placeholder?: string;
/** Prefills the field and is selected on open, so typing replaces it. */
readonly initialValue?: string;
readonly cancelText?: string;
readonly confirmText: string;
/** Receives the raw field text; the caller owns trimming and no-op rules. */
readonly onConfirm: (value: string) => void;
readonly onCancel?: () => void;
};

type DialogRequest =
| { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
| { readonly kind: "prompt"; readonly request: PromptDialogRequest };

let presentDialog: ((dialog: DialogRequest) => void) | null = null;

/**
* Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already
Expand All @@ -24,7 +42,16 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
* once. Requires ConfirmDialogHost to be mounted at the app root.
*/
export function showConfirmDialog(request: ConfirmDialogRequest): void {
presentRequest?.(request);
presentDialog?.({ kind: "confirm", request });
}

/**
* Imperative single-field text prompt. Unlike showConfirmDialog this is the
* only option on both platforms: Alert.prompt is iOS-only. Confirm stays
* disabled while the field is blank. Requires ConfirmDialogHost at the root.
*/
export function showPromptDialog(request: PromptDialogRequest): void {
presentDialog?.({ kind: "prompt", request });
}

/**
Expand All @@ -34,77 +61,108 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void {
* button color and a dimmer message than the title.
*/
export function ConfirmDialogHost() {
const [request, setRequest] = useState<ConfirmDialogRequest | null>(null);
const [dialog, setDialog] = useState<DialogRequest | null>(null);
const [draft, setDraft] = useState("");
const pressedOverlay = useThemeColor("--color-subtle");

useEffect(() => {
presentRequest = setRequest;
presentDialog = (next) => {
setDialog(next);
setDraft(next.kind === "prompt" ? (next.request.initialValue ?? "") : "");
};
return () => {
presentRequest = null;
presentDialog = null;
};
}, []);

const handleCancel = useCallback(() => {
request?.onCancel?.();
setRequest(null);
}, [request]);
dialog?.request.onCancel?.();
setDialog(null);
}, [dialog]);

const confirmDisabled = dialog?.kind === "prompt" && draft.trim().length === 0;

const handleConfirm = useCallback(() => {
request?.onConfirm();
setRequest(null);
}, [request]);
if (dialog === null) return;
if (dialog.kind === "prompt") {
if (draft.trim().length === 0) return;
dialog.request.onConfirm(draft);
} else {
dialog.request.onConfirm();
}
setDialog(null);
}, [dialog, draft]);

return (
<Modal
visible={request !== null}
visible={dialog !== null}
transparent
animationType="fade"
statusBarTranslucent
navigationBarTranslucent
onRequestClose={handleCancel}
>
{request === null ? null : (
<View className="flex-1 items-center justify-center bg-backdrop px-8">
<View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">
<AppText className="text-lg font-t3-medium">{request.title}</AppText>
{request.message === undefined ? null : (
<AppText className="mt-2 text-sm text-foreground-secondary">
{request.message}
</AppText>
)}
<View className="mt-5 flex-row justify-end gap-1">
<View className="overflow-hidden rounded-full">
<Pressable
accessibilityRole="button"
className="min-h-10 items-center justify-center px-4"
android_ripple={{ color: pressedOverlay }}
onPress={handleCancel}
>
<AppText className="text-base font-t3-medium">
{request.cancelText ?? "Cancel"}
</AppText>
</Pressable>
</View>
<View className="overflow-hidden rounded-full">
<Pressable
accessibilityRole="button"
className="min-h-10 items-center justify-center px-4"
android_ripple={{ color: pressedOverlay }}
onPress={handleConfirm}
>
<AppText
className={cn(
"text-base font-t3-medium",
request.destructive && "text-danger-foreground",
)}
{dialog === null ? null : (
<KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
<View className="flex-1 items-center justify-center bg-backdrop px-8">
<View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">
<AppText className="text-lg font-t3-medium">{dialog.request.title}</AppText>
{dialog.request.message === undefined ? null : (
<AppText className="mt-2 text-sm text-foreground-secondary">
{dialog.request.message}
</AppText>
)}
{dialog.kind === "prompt" ? (
<TextInput
autoFocus
className="mt-4"
onChangeText={setDraft}
onSubmitEditing={handleConfirm}
placeholder={dialog.request.placeholder}
returnKeyType="done"
selectTextOnFocus
value={draft}
/>
) : null}
<View className="mt-5 flex-row justify-end gap-1">
<View className="overflow-hidden rounded-full">
<Pressable
accessibilityRole="button"
className="min-h-10 items-center justify-center px-4"
android_ripple={{ color: pressedOverlay }}
onPress={handleCancel}
>
<AppText className="text-base font-t3-medium">
{dialog.request.cancelText ?? "Cancel"}
</AppText>
</Pressable>
</View>
<View className="overflow-hidden rounded-full">
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: confirmDisabled }}
className="min-h-10 items-center justify-center px-4"
android_ripple={{ color: pressedOverlay }}
disabled={confirmDisabled}
onPress={handleConfirm}
>
{request.confirmText}
</AppText>
</Pressable>
<AppText
className={cn(
"text-base font-t3-medium",
dialog.kind === "confirm" &&
dialog.request.destructive &&
"text-danger-foreground",
confirmDisabled && "text-foreground-muted",
)}
>
{dialog.request.confirmText}
</AppText>
</Pressable>
</View>
</View>
</View>
</View>
</View>
</KeyboardAvoidingView>
)}
</Modal>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function HomeRouteScreen() {
pinThread,
unpinThread,
movePinnedThread,
renameThread,
regenerateThreadTitle,
unsettleThread,
} = useThreadListActions();
Expand Down Expand Up @@ -195,6 +196,7 @@ export function HomeRouteScreen() {
onPinThread={pinThread}
onUnpinThread={unpinThread}
onMovePinnedThread={movePinnedThread}
onRenameThread={renameThread}
onRegenerateThreadTitle={regenerateThreadTitle}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ interface HomeScreenProps {
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
Expand Down Expand Up @@ -845,6 +846,7 @@ export function HomeScreen(props: HomeScreenProps) {
onSelectThread={props.onSelectThread}
onDeleteThread={handleDeleteThread}
onArchiveThread={props.onArchiveThread}
onRenameThread={props.onRenameThread}
onRegenerateThreadTitle={handleRegenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
Expand Down Expand Up @@ -1006,6 +1008,7 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery={props.searchQuery}
onArchiveThread={props.onArchiveThread}
onDeleteThread={props.onDeleteThread}
onRenameThread={props.onRenameThread}
onRegenerateThreadTitle={handleRegenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
onSelectThread={props.onSelectThread}
Expand Down
43 changes: 42 additions & 1 deletion apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
import { Alert } from "react-native";

import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { showConfirmDialog, showPromptDialog } from "../../components/ConfirmDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
import {
Expand Down Expand Up @@ -236,6 +236,7 @@ export function useThreadListActions(): {
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
readonly renameThread: (thread: EnvironmentThreadShell) => void;
readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
Expand Down Expand Up @@ -419,6 +420,45 @@ export function useThreadListActions(): {
},
[unpinMutation],
);
/** Same commit rule as web's resolveRenameCommit: trim, drop an empty
title, and skip the write when nothing changed. */
const commitRename = useCallback(
async (thread: EnvironmentThreadShell, title: string) => {
const trimmed = title.trim();
if (trimmed.length === 0 || trimmed === thread.title) return;
selectionHaptic();
const result = await updateThreadMetadata({
environmentId: thread.environmentId,
input: { threadId: thread.id, title: trimmed },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Alert.alert(
"Could not rename thread",
error instanceof Error && error.message.trim().length > 0
? error.message
: "The thread could not be renamed.",
);
}
},
[updateThreadMetadata],
);
/** Prompts for a new title, then writes it. Not capability-gated: every
server that accepts thread.meta.update accepts a manual title. */
const renameThread = useCallback(
(thread: EnvironmentThreadShell) => {
showPromptDialog({
title: "Rename thread",
confirmText: "Rename",
initialValue: thread.title,
placeholder: "Thread title",
onConfirm: (value) => {
void commitRename(thread, value);
},
});
},
[commitRename],
);
const regenerateThreadTitle = useCallback(
async (thread: EnvironmentThreadShell) => {
const key = scopedThreadKey(thread.environmentId, thread.id);
Expand Down Expand Up @@ -552,6 +592,7 @@ export function useThreadListActions(): {
pinThread,
unpinThread,
movePinnedThread,
renameThread,
regenerateThreadTitle,
};
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ function ThreadNavigationSidebarPane(
pinThread,
unpinThread,
movePinnedThread,
renameThread,
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
Expand Down Expand Up @@ -923,6 +924,7 @@ function ThreadNavigationSidebarPane(
onSelectThread={handleSelectThread}
onDeleteThread={confirmDeleteThread}
onArchiveThread={archiveThread}
onRenameThread={renameThread}
onRegenerateThreadTitle={regenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
Expand Down Expand Up @@ -1044,6 +1046,7 @@ function ThreadNavigationSidebarPane(
fullSwipeWidth={props.width - 20}
onArchiveThread={archiveThread}
onDeleteThread={confirmDeleteThread}
onRenameThread={renameThread}
onRegenerateThreadTitle={regenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
onSelectThread={handleSelectThread}
Expand Down Expand Up @@ -1083,6 +1086,7 @@ function ThreadNavigationSidebarPane(
projectCwdByKey,
projectTitleByProjectKey,
regenerateThreadTitle,
renameThread,
props.onNewThreadInProject,
props.searchQuery,
props.selectedThreadKey,
Expand Down
Loading
Loading