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
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati

import { RegistryContext } from "@effect/atom-react";
import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
import { RenameThreadDialogHost } from "./components/RenameThreadDialogHost";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
Expand Down Expand Up @@ -88,6 +89,7 @@ export default function App() {
/>
</IncomingShareProvider>
<ConfirmDialogHost />
<RenameThreadDialogHost />
</BlurTargetView>
{/* Anchored-menu overlays render here — in-window, so the
keyboard stays up while a dropdown is open. */}
Expand Down
142 changes: 142 additions & 0 deletions apps/mobile/src/components/RenameThreadDialogHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Modal, Pressable, TextInput, View } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";

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

export type RenameThreadDialogRequest = {
/** Dialog heading. Defaults to "Rename thread". */
readonly title?: string;
/** Prefills the field; the user edits from here. */
readonly initialValue: string;
readonly confirmText?: string;
readonly cancelText?: string;
/** Called with the trimmed value only when it is non-empty and changed. */
readonly onSubmit: (value: string) => void;
readonly onCancel?: () => void;
};

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

/**
* Imperative rename dialog. React Native's Alert.prompt is iOS-only, so
* renaming a thread needs a custom modal to work on Android — this is the
* text-input sibling of showConfirmDialog. Requires RenameThreadDialogHost to
* be mounted at the app root.
*/
export function showRenameThreadDialog(request: RenameThreadDialogRequest): void {
presentRequest?.(request);
}

/**
* Single-field rename dialog styled to match ConfirmDialogHost: a centered
* card with a title, a prefilled text field, and Cancel / Rename actions.
* Rename stays disabled until the trimmed value is non-empty.
*/
export function RenameThreadDialogHost() {
const [request, setRequest] = useState<RenameThreadDialogRequest | null>(null);
const [value, setValue] = useState("");
const inputRef = useRef<TextInput>(null);
const pressedOverlay = useThemeColor("--color-subtle");

useEffect(() => {
presentRequest = (next) => {
setRequest(next);
setValue(next.initialValue);
};
return () => {
presentRequest = null;
};
}, []);

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

const trimmed = value.trim();
const canSubmit = trimmed.length > 0;

const handleConfirm = useCallback(() => {
if (request === null) return;
const next = value.trim();
if (next.length === 0) return;
// A no-op rename still dismisses the dialog, but never dispatches a
// pointless metadata update for an unchanged title.
if (next !== request.initialValue.trim()) {
request.onSubmit(next);
}
setRequest(null);
}, [request, value]);

return (
<Modal
visible={request !== null}
transparent
animationType="fade"
statusBarTranslucent
navigationBarTranslucent
onRequestClose={handleCancel}
onShow={() => inputRef.current?.focus()}
>
{request === null ? null : (
<KeyboardAvoidingView automaticOffset behavior="padding" className="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">
{request.title ?? "Rename thread"}
</AppText>
<AppTextInput
ref={inputRef}
className="mt-4"
value={value}
onChangeText={setValue}
autoFocus
selectTextOnFocus
returnKeyType="done"
onSubmitEditing={handleConfirm}
placeholder="Thread name"
accessibilityLabel="Thread name"
/>
<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"
accessibilityState={{ disabled: !canSubmit }}
className="min-h-10 items-center justify-center px-4"
android_ripple={{ color: pressedOverlay }}
disabled={!canSubmit}
onPress={handleConfirm}
>
<AppText
className={cn(
"text-base font-t3-medium",
canSubmit || "text-foreground-tertiary",
)}
>
{request.confirmText ?? "Rename"}
</AppText>
</Pressable>
</View>
</View>
</View>
</View>
</KeyboardAvoidingView>
)}
</Modal>
);
}
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 @@ -46,6 +46,7 @@ export function HomeRouteScreen() {
unpinThread,
movePinnedThread,
unsettleThread,
renameThread,
} = useThreadListActions();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
Expand Down Expand Up @@ -165,6 +166,7 @@ export function HomeRouteScreen() {
}
onArchiveThread={archiveThread}
onDeleteThread={confirmDeleteThread}
onRenameThread={renameThread}
onSettleThread={settleThread}
onSnoozeThread={snoozeThread}
onUnsnoozeThread={unsnoozeThread}
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ interface HomeScreenProps {
readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
/** Resolves true iff the settle was dispatched and succeeded. */
readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onSnoozeThread: (
Expand Down Expand Up @@ -811,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) {
onSelectThread={props.onSelectThread}
onDeleteThread={handleDeleteThread}
onArchiveThread={props.onArchiveThread}
onRenameThread={props.onRenameThread}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={handleSettleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
Expand Down Expand Up @@ -854,6 +856,7 @@ export function HomeScreen(props: HomeScreenProps) {
projectByKey,
projectCwdByKey,
props.onArchiveThread,
props.onRenameThread,
props.onDeletePendingTask,
props.onSelectPendingTask,
props.onSelectThread,
Expand Down Expand Up @@ -967,6 +970,7 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery={props.searchQuery}
onArchiveThread={props.onArchiveThread}
onDeleteThread={props.onDeleteThread}
onRenameThread={props.onRenameThread}
onSelectThread={props.onSelectThread}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
Expand All @@ -992,6 +996,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.onArchiveThread,
props.onDeletePendingTask,
props.onDeleteThread,
props.onRenameThread,
props.onNewThreadInProject,
props.onSelectPendingTask,
props.onSelectThread,
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native";

import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { showRenameThreadDialog } from "../../components/RenameThreadDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
import {
Expand Down Expand Up @@ -227,8 +228,12 @@ export function useThreadListActions(): {
thread: EnvironmentThreadShell,
direction: "up" | "down",
) => Promise<boolean>;
readonly renameThread: (thread: EnvironmentThreadShell) => void;
} {
const executeAction = useThreadActionExecutor();
const updateMetadataMutation = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false });
const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false });
Expand Down Expand Up @@ -485,6 +490,33 @@ export function useThreadListActions(): {
[reorderPinnedMutation],
);

const renameThread = useCallback(
(thread: EnvironmentThreadShell) => {
showRenameThreadDialog({
initialValue: thread.title,
onSubmit: (title) => {
void (async () => {
selectionHaptic();
const result = await updateMetadataMutation({
environmentId: thread.environmentId,
input: { threadId: thread.id, title },
});
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.",
);
}
})();
},
});
},
[updateMetadataMutation],
);

const confirmDeleteThread = useConfirmDeleteThread(executeAction);

return {
Expand All @@ -497,6 +529,7 @@ export function useThreadListActions(): {
pinThread,
unpinThread,
movePinnedThread,
renameThread,
};
}

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 @@ -211,6 +211,7 @@ function ThreadNavigationSidebarPane(
pinThread,
unpinThread,
movePinnedThread,
renameThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const pendingTasks = usePendingNewTasks();
Expand Down Expand Up @@ -950,6 +951,7 @@ function ThreadNavigationSidebarPane(
onSelectThread={handleSelectThread}
onDeleteThread={confirmDeleteThread}
onArchiveThread={archiveThread}
onRenameThread={renameThread}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
onSettleThread={settleThread}
snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
Expand Down Expand Up @@ -1067,6 +1069,7 @@ function ThreadNavigationSidebarPane(
fullSwipeWidth={props.width - 20}
onArchiveThread={archiveThread}
onDeleteThread={confirmDeleteThread}
onRenameThread={renameThread}
onSelectThread={handleSelectThread}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
Expand Down Expand Up @@ -1097,6 +1100,7 @@ function ThreadNavigationSidebarPane(
handleSwipeableWillOpen,
movePinnedThread,
openPendingTask,
renameThread,
pinReorderEnvironmentIds,
pinThread,
pinningEnvironmentIds,
Expand Down
34 changes: 28 additions & 6 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,17 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {

/* ─── Thread row ─────────────────────────────────────────────────────── */

const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
{ id: "archive", title: "Archive", image: "archivebox" },
{ id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
];
const THREAD_ROW_MENU_ARCHIVE: MenuAction = {
id: "archive",
title: "Archive",
image: "archivebox",
};
const THREAD_ROW_MENU_DELETE: MenuAction = {
id: "delete",
title: "Delete",
image: "trash",
attributes: { destructive: true },
};

export const ThreadListRow = memo(function ThreadListRow(props: {
readonly variant: ThreadListVariant;
Expand All @@ -429,6 +436,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
/** Adds a "Rename" menu item when provided. */
readonly onRenameThread?: (thread: EnvironmentThreadShell) => void;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
readonly simultaneousSwipeGesture?: ComponentProps<
Expand All @@ -451,6 +460,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
const selectedBackgroundColor = useThemeColor("--color-user-bubble");

const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props;
const { onRenameThread } = props;
const status = resolveThreadStatus(thread);
const pr = useThreadPr(thread, props.projectCwd);
const timestamp = relativeTime(
Expand All @@ -470,6 +480,17 @@ 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 menuActions = useMemo<MenuAction[]>(
() => [
...(onRenameThread != null
? [{ id: "rename", title: "Rename", image: "square.and.pencil" } satisfies MenuAction]
: []),
THREAD_ROW_MENU_ARCHIVE,
THREAD_ROW_MENU_DELETE,
],
[onRenameThread],
);
const primaryAction = useMemo(
() => ({
accessibilityLabel: `Archive ${thread.title}`,
Expand All @@ -481,10 +502,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
);
const handleMenuAction = useCallback(
({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
if (nativeEvent.event === "rename") handleRename();
if (nativeEvent.event === "archive") handleArchive();
if (nativeEvent.event === "delete") handleDelete();
},
[handleArchive, handleDelete],
[handleArchive, handleDelete, handleRename],
);

const statusPill = effectiveStatus ? (
Expand Down Expand Up @@ -673,7 +695,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
// ControlPillMenu injects onLongPress into the row and anchors the
// token-styled dropdown to it; taps and swipes are untouched.
<ControlPillMenu
actions={THREAD_ROW_MENU_ACTIONS}
actions={menuActions}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
>
Expand Down
Loading
Loading