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
83 changes: 63 additions & 20 deletions apps/mobile/src/components/ConfirmDialogHost.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Modal, Pressable, View } from "react-native";
import { Modal, Pressable, TextInput, View } from "react-native";

import { cn } from "../lib/cn";
import { AppText } from "./AppText";
Expand All @@ -14,7 +14,20 @@ export type ConfirmDialogRequest = {
readonly onCancel?: () => void;
};

let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
export type TextInputDialogRequest = {
readonly title: string;
readonly initialValue: string;
readonly cancelText?: string;
readonly confirmText: string;
readonly onConfirm: (value: string) => void;
readonly onCancel?: () => void;
};

type DialogRequest =
| { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
| { readonly kind: "text-input"; readonly request: TextInputDialogRequest };

let presentRequest: ((request: DialogRequest) => void) | null = null;

/**
* Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already
Expand All @@ -23,7 +36,11 @@ 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);
presentRequest?.({ kind: "confirm", request });
}

export function showTextInputDialog(request: TextInputDialogRequest): void {
presentRequest?.({ kind: "text-input", request });
}

/**
Expand All @@ -33,42 +50,64 @@ 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 [presented, setPresented] = useState<DialogRequest | null>(null);
const [inputValue, setInputValue] = useState("");
useEffect(() => {
presentRequest = setRequest;
presentRequest = (request) => {
setInputValue(request.kind === "text-input" ? request.request.initialValue : "");
setPresented(request);
};
return () => {
presentRequest = null;
};
}, []);

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

const handleConfirm = useCallback(() => {
request?.onConfirm();
setRequest(null);
}, [request]);
if (presented?.kind === "confirm") {
presented.request.onConfirm();
} else if (presented?.kind === "text-input") {
presented.request.onConfirm(inputValue);
}
setPresented(null);
}, [inputValue, presented]);

const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0;

return (
<Modal
visible={request !== null}
visible={presented !== null}
transparent
animationType="fade"
statusBarTranslucent
navigationBarTranslucent
onRequestClose={handleCancel}
>
{request === null ? null : (
{presented === 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="text-lg font-t3-medium">{presented.request.title}</AppText>
{presented.kind === "confirm" && presented.request.message !== undefined ? (
<AppText className="mt-2 text-sm text-foreground-secondary">
{request.message}
{presented.request.message}
</AppText>
)}
) : null}
{presented.kind === "text-input" ? (
<TextInput
accessibilityLabel={presented.request.title}
autoFocus
className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"
onChangeText={setInputValue}
onSubmitEditing={confirmDisabled ? undefined : handleConfirm}
returnKeyType="done"
selectTextOnFocus
value={inputValue}
/>
) : null}
<View className="mt-5 flex-row justify-end gap-1">
<View className="overflow-hidden rounded-full">
<Pressable
Expand All @@ -77,23 +116,27 @@ export function ConfirmDialogHost() {
onPress={handleCancel}
>
<AppText className="text-base font-t3-medium">
{request.cancelText ?? "Cancel"}
{presented.request.cancelText ?? "Cancel"}
</AppText>
</Pressable>
</View>
<View className="overflow-hidden rounded-full">
<Pressable
accessibilityRole="button"
disabled={confirmDisabled}
className="min-h-10 items-center justify-center px-4 active:bg-subtle"
onPress={handleConfirm}
>
<AppText
className={cn(
"text-base font-t3-medium",
request.destructive && "text-danger-foreground",
presented.kind === "confirm" &&
presented.request.destructive &&
"text-danger-foreground",
confirmDisabled && "text-foreground-tertiary",
)}
>
{request.confirmText}
{presented.request.confirmText}
</AppText>
</Pressable>
</View>
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 @@ -50,6 +50,7 @@ export function HomeRouteScreen() {
pinThread,
unpinThread,
moveThread,
renameThread,
regenerateThreadTitle,
unsettleThread,
} = useThreadListActions();
Expand Down Expand Up @@ -200,6 +201,7 @@ export function HomeRouteScreen() {
onPinThread={pinThread}
onUnpinThread={unpinThread}
onMoveThread={moveThread}
onRenameThread={renameThread}
onRegenerateThreadTitle={regenerateThreadTitle}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ interface HomeScreenProps {
thread: EnvironmentThreadShell,
direction: ThreadMoveDestination,
) => 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 @@ -537,6 +538,10 @@ export function HomeScreen(props: HomeScreenProps) {
},
[props.onRegenerateThreadTitle],
);
const handleRenameThread = useCallback(
(thread: EnvironmentThreadShell) => props.onRenameThread(thread),
[props.onRenameThread],
);
const handleDeleteThread = props.onDeleteThread;
const handleUnsettleThread = props.onUnsettleThread;
// The settled tail renders in pages; expansion resets when the filter
Expand Down Expand Up @@ -862,6 +867,7 @@ export function HomeScreen(props: HomeScreenProps) {
onSelectThread={props.onSelectThread}
onDeleteThread={handleDeleteThread}
onArchiveThread={props.onArchiveThread}
onRenameThread={handleRenameThread}
onRegenerateThreadTitle={handleRegenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
Expand Down Expand Up @@ -895,6 +901,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleMoveThread,
handlePinThread,
handleRegenerateThreadTitle,
handleRenameThread,
handleSettleThread,
handleSnoozeThread,
handleUnpinThread,
Expand Down Expand Up @@ -1019,6 +1026,7 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery={props.searchQuery}
onArchiveThread={props.onArchiveThread}
onDeleteThread={props.onDeleteThread}
onRenameThread={handleRenameThread}
onRegenerateThreadTitle={handleRegenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
onSelectThread={props.onSelectThread}
Expand All @@ -1043,6 +1051,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleSwipeableClose,
handleSwipeableWillOpen,
handleRegenerateThreadTitle,
handleRenameThread,
machineByEnvironmentId,
queuedThreadKeys,
props.onArchiveThread,
Expand Down
51 changes: 49 additions & 2 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa
import * as Cause from "effect/Cause";
import * as Haptics from "expo-haptics";
import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Alert, Platform } from "react-native";

import { withThreadDismissal } from "./thread-dismissal";
import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
Expand All @@ -27,6 +27,7 @@ import {
threadDropLifecycle,
} from "../threads/threadOrder";
import { getThreadListV2OrderedSection } from "../threads/threadListV2";
import { resolveThreadTitleRename } from "../threads/thread-title-rename";

/** Version skew: never send settle/unsettle to a server that predates them
(capability defaults false on decode for older servers). */
Expand Down Expand Up @@ -240,6 +241,7 @@ export function useThreadListActions(): {
thread: EnvironmentThreadShell,
direction: ThreadMoveDestination,
) => Promise<boolean>;
readonly renameThread: (thread: EnvironmentThreadShell) => void;
readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
} {
const executeAction = useThreadActionExecutor();
Expand Down Expand Up @@ -474,6 +476,50 @@ export function useThreadListActions(): {
},
[updateThreadMetadata],
);
const renameThread = useCallback(
(thread: EnvironmentThreadShell) => {
const commit = (title: string) => {
const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title });
if (resolution.action === "reject-empty") {
Alert.alert("Could not rename thread", "Thread title cannot be empty.");
return;
}
if (resolution.action === "noop") return;
selectionHaptic();
void updateThreadMetadata({
environmentId: thread.environmentId,
input: { threadId: thread.id, title: resolution.title },
}).then((result) => {
if (result._tag === "Success") return;
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.",
);
});
};

if (Platform.OS === "ios") {
Alert.prompt(
"Rename thread",
undefined,
(title) => commit(title ?? ""),
"plain-text",
thread.title,
);
return;
}
showTextInputDialog({
title: "Rename thread",
initialValue: thread.title,
confirmText: "Rename",
onConfirm: commit,
});
},
[updateThreadMetadata],
);

// Plan against the complete section so filtering does not change a move.
const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
Expand Down Expand Up @@ -653,6 +699,7 @@ export function useThreadListActions(): {
pinThread,
unpinThread,
moveThread,
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,
moveThread,
renameThread,
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
Expand Down Expand Up @@ -930,6 +931,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 @@ -1047,6 +1049,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 @@ -1089,6 +1092,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectTitleByProjectKey,
regenerateThreadTitle,
renameThread,
props.onNewThreadInProject,
props.onNewThreadOnBranch,
props.searchQuery,
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {

const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
{ id: "archive", title: "Archive", image: "archivebox" },
{ id: "rename", title: "Rename", image: "square.and.pencil" },
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
];

Expand All @@ -459,6 +460,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
readonly titleRegenerationSupported: boolean;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
Expand Down Expand Up @@ -490,6 +492,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
onSelectThread,
onArchiveThread,
onDeleteThread,
onRenameThread,
onRegenerateThreadTitle,
onNewThreadOnBranch,
} = props;
Expand Down Expand Up @@ -534,6 +537,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {

const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
const handleRegenerateTitle = useCallback(
() => onRegenerateThreadTitle(thread),
[onRegenerateThreadTitle, thread],
Expand All @@ -551,11 +555,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
]
: []),
THREAD_ROW_MENU_ACTIONS[0]!,
THREAD_ROW_MENU_ACTIONS[1]!,
...buildThreadTitleRegenerationMenuItems({
supported: props.titleRegenerationSupported,
isRegenerating: thread.titleRegeneration != null,
}),
THREAD_ROW_MENU_ACTIONS[1]!,
THREAD_ROW_MENU_ACTIONS[2]!,
],
[props.titleRegenerationSupported, thread.branch, thread.titleRegeneration],
);
Expand All @@ -572,10 +577,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
if (nativeEvent.event === "archive") handleArchive();
if (nativeEvent.event === "rename") handleRename();
if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
if (nativeEvent.event === "delete") handleDelete();
},
[handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread],
[handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread],
);

const statusPill = effectiveStatus ? (
Expand Down
Loading
Loading