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
2 changes: 2 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentCompo
import { ReviewSheet } from "./features/review/ReviewSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen";
import { StopThreadProcessesFeedbackOverlay } from "./features/threads/StopThreadProcessesFeedbackOverlay";
import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
Expand Down Expand Up @@ -614,6 +615,7 @@ function RootStackLayout(props: {
return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<StopThreadProcessesFeedbackOverlay />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ExistingThreadSettingsRouteProvider>
<AdaptiveWorkspaceLayout
Expand Down
97 changes: 51 additions & 46 deletions apps/mobile/src/components/MaterialMenuPopup.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import {
DropdownMenu,
DropdownMenuItem,
Host,
HorizontalDivider,
RNHostView,
Text,
} from "@expo/ui/jetpack-compose";
import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers";
import { View } from "react-native";
import { Fragment } from "react";
import { resolveScaledTextRole } from "../lib/appearancePreferences";
import { flattenInlineMenuGroups } from "../lib/inline-menu-groups";

import { useAndroidControlSizing } from "./useAndroidControlSizing";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
Expand Down Expand Up @@ -77,53 +80,55 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) {
{props.title}
</Text>
) : null}
{props.actions.map((action, index) => (
<DropdownMenuItem
key={action.id ?? `${index}-${action.title}`}
enabled={!action.attributes?.disabled}
modifiers={itemModifiers}
onClick={() => props.onPress(action)}
>
{action.image && isAppSymbolName(action.image) ? (
<DropdownMenuItem.LeadingIcon>
<MenuIcon
name={action.image}
destructive={action.attributes?.destructive}
disabled={action.attributes?.disabled}
/>
</DropdownMenuItem.LeadingIcon>
) : null}
<DropdownMenuItem.Text>
<Column>
<Text
style={{ typography: "bodyLarge", ...body }}
color={
action.attributes?.disabled
? muted
: action.attributes?.destructive
? colors["--color-danger-foreground"]
: foreground
}
>
{action.title}
</Text>
{action.subtitle ? (
<Text color={muted} style={{ typography: "bodySmall", ...caption }}>
{action.subtitle}
{flattenInlineMenuGroups(props.actions).map(({ action, separatorBefore }, index) => (
<Fragment key={action.id ?? `${index}-${action.title}`}>
{separatorBefore ? <HorizontalDivider color={colors["--color-border"]} /> : null}
<DropdownMenuItem
enabled={!action.attributes?.disabled}
modifiers={itemModifiers}
onClick={() => props.onPress(action)}
>
{action.image && isAppSymbolName(action.image) ? (
<DropdownMenuItem.LeadingIcon>
<MenuIcon
name={action.image}
destructive={action.attributes?.destructive}
disabled={action.attributes?.disabled}
/>
</DropdownMenuItem.LeadingIcon>
) : null}
<DropdownMenuItem.Text>
<Column>
<Text
style={{ typography: "bodyLarge", ...body }}
color={
action.attributes?.disabled
? muted
: action.attributes?.destructive
? colors["--color-danger-foreground"]
: foreground
}
>
{action.title}
</Text>
) : null}
</Column>
</DropdownMenuItem.Text>
{(action.subactions?.length ?? 0) > 0 ? (
<DropdownMenuItem.TrailingIcon>
<MenuIcon name="chevron.right" disabled={action.attributes?.disabled} />
</DropdownMenuItem.TrailingIcon>
) : action.state === "on" ? (
<DropdownMenuItem.TrailingIcon>
<MenuIcon name="checkmark" disabled={action.attributes?.disabled} />
</DropdownMenuItem.TrailingIcon>
) : null}
</DropdownMenuItem>
{action.subtitle ? (
<Text color={muted} style={{ typography: "bodySmall", ...caption }}>
{action.subtitle}
</Text>
) : null}
</Column>
</DropdownMenuItem.Text>
{(action.subactions?.length ?? 0) > 0 ? (
<DropdownMenuItem.TrailingIcon>
<MenuIcon name="chevron.right" disabled={action.attributes?.disabled} />
</DropdownMenuItem.TrailingIcon>
) : action.state === "on" ? (
<DropdownMenuItem.TrailingIcon>
<MenuIcon name="checkmark" disabled={action.attributes?.disabled} />
</DropdownMenuItem.TrailingIcon>
) : null}
</DropdownMenuItem>
</Fragment>
))}
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useAtomValue } from "@effect/atom-react";

import { stopThreadProcessesFeedbackAtom } from "../../state/stop-thread-processes-feedback";
import { GitActionProgressOverlay } from "./GitActionProgressOverlay";

const keepFeedbackVisible = () => {};

export function StopThreadProcessesFeedbackOverlay() {
const progress = useAtomValue(stopThreadProcessesFeedbackAtom);
return <GitActionProgressOverlay progress={progress} onDismiss={keepFeedbackVisible} />;
}
123 changes: 99 additions & 24 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RowPressable } from "../../components/RowPressable";
import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
import { appAtomRegistry } from "../../state/atom-registry";
import { threadArrangementOpenAtom } from "../../state/thread-order";
import { environmentServerConfigsAtom } from "../../state/server";
import type { ThreadMoveDestination } from "./threadOrder";
import type {
EnvironmentProject,
Expand All @@ -20,10 +21,14 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state
import type { EnvironmentMachineKind } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
import { actionRunningPresentation } from "@t3tools/shared/actionResume";
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import * as Cause from "effect/Cause";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/unstable/reactivity";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Alert, Pressable, useWindowDimensions, View } from "react-native";
import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";

import type { ThreadListProvider } from "../../state/thread-list-environments";
Expand All @@ -39,6 +44,8 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { threadEnvironment } from "../../state/threads";
import { terminalEnvironment } from "../../state/terminal";
import { previewEnvironment } from "../../state/preview";
import { beginStopThreadProcessesFeedback } from "../../state/stop-thread-processes-feedback";
import { useAtomCommand } from "../../state/use-atom-command";
import { useThreadPr } from "../../state/use-thread-pr";
import { useSwipeRowDormant } from "../home/swipe-row-activation";
Expand All @@ -60,6 +67,7 @@ import { PersistentThreadIcon } from "./PersistentThreadIcon";
import {
buildThreadPersistenceMenuItems,
persistenceIntentForMenuEvent,
withThreadMenuDividers,
} from "./thread-persistence-menu";
import { resolveWorktreeCleanupStatus, shouldShowActionWaitingIndicator } from "./thread-status";
import { ThreadSearchMatchExcerpt } from "./thread-search-match";
Expand Down Expand Up @@ -595,6 +603,38 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
reportFailure: false,
});
const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
const stopThreadProcesses = useAtomCommand(previewEnvironment.hostingStopThread, {
reportFailure: false,
});
const hasRunningSubprocess = useAtomValue(
terminalEnvironment.metadata({ environmentId: thread.environmentId, input: null }),
useCallback(
(result) =>
Option.exists(AsyncResult.value(result), (terminals) =>
terminals.some(
(terminal) => terminal.threadId === thread.id && terminal.hasRunningSubprocess,
),
),
[thread.id],
),
);
const hasPreviewLease = useAtomValue(
previewEnvironment.hostingLeases({ environmentId: thread.environmentId, input: {} }),
useCallback(
(result) =>
Option.exists(AsyncResult.value(result), (leases) =>
leases.some((lease) => lease.threadId === thread.id),
),
[thread.id],
),
);
const supportsProcessControls = useAtomValue(
environmentServerConfigsAtom,
(configs) =>
configs.get(thread.environmentId)?.environment.capabilities.previewHostingProcessControl ===
true,
);
const hasManagedProcesses = supportsProcessControls && (hasRunningSubprocess || hasPreviewLease);

const { providerDrivers, providerIconUrl } = useMemo(() => {
const provider = props.providers?.find(
Expand Down Expand Up @@ -703,6 +743,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
}
}, [closeTerminal, runningAction, thread.environmentId, thread.id]);

const handleStopThreadProcesses = useCallback(async () => {
const finishFeedback = beginStopThreadProcessesFeedback(thread.title);
const result = await stopThreadProcesses({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Success") {
finishFeedback("success");
} else if (isAtomCommandInterrupted(result)) {
finishFeedback("interrupted");
} else {
finishFeedback("error");
Comment thread
lastobelus marked this conversation as resolved.
const error = Cause.squash(result.cause);
Alert.alert(
"Could not stop all previews and processes",
error instanceof Error ? error.message : "The previews and processes could not be stopped.",
);
}
}, [stopThreadProcesses, thread.environmentId, thread.id, thread.title]);

const handleRetryWorktreeCleanup = useCallback(async () => {
const result = await retryWorktreeCleanup({
environmentId: thread.environmentId,
Expand Down Expand Up @@ -874,11 +934,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const withPersistence = useCallback(
(actions: ReadonlyArray<MenuAction>) =>
buildThreadPersistenceMenuItems({
actions,
actions: hasManagedProcesses
? [
{
id: "stop-thread-processes",
title: "Stop all previews & processes",
image: "stop.fill",
attributes: { destructive: true },
},
...actions,
]
: actions,
persistent: thread.persistent === true,
supported: props.persistenceSupported,
}),
[props.persistenceSupported, thread.persistent],
[hasManagedProcesses, props.persistenceSupported, thread.persistent],
);
const snoozableCardMenuActions = useMemo<MenuAction[]>(
() =>
Expand Down Expand Up @@ -975,6 +1045,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
copyTextWithHaptic(thread.id, { target: "thread-id" });
}
if (nativeEvent.event === "cancel-action") void handleCancelAction();
if (nativeEvent.event === "stop-thread-processes") void handleStopThreadProcesses();
if (nativeEvent.event === "retry-worktree-cleanup") void handleRetryWorktreeCleanup();
if (nativeEvent.event === "keep-worktree") handleKeepWorktree();
const persistenceIntent = persistenceIntentForMenuEvent(nativeEvent.event);
Expand All @@ -1000,6 +1071,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
thread,
handleArchive,
handleCancelAction,
handleStopThreadProcesses,
handleKeepWorktree,
handlePersistence,
handleRetryWorktreeCleanup,
Expand Down Expand Up @@ -1471,27 +1543,30 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
? FAILED_CLEANUP_MENU_ACTIONS
: cleanupPending
? []
: [
...(thread.branch
? [
{
id: "new-thread-on-branch",
title: getThreadListV2NewBranchMenuTitle(thread.branch),
image: "square.and.pencil",
},
]
: []),
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
...(snoozedRow
? snoozedMenuActions
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions),
]
: withThreadMenuDividers(
[
...(thread.branch
? [
{
id: "new-thread-on-branch",
title: getThreadListV2NewBranchMenuTitle(thread.branch),
image: "square.and.pencil",
},
]
: []),
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
...(snoozedRow
? snoozedMenuActions
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions),
],
Platform.OS === "ios" || Platform.OS === "android",
)
}
onPressAction={handleMenuAction}
shouldOpenOnLongPress={cleanupFailed || !cleanupPending}
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/features/threads/thread-persistence-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
buildThreadPersistenceMenuItems,
persistenceIntentForMenuEvent,
withThreadMenuDividers,
} from "./thread-persistence-menu.ts";

describe("buildThreadPersistenceMenuItems", () => {
Expand Down Expand Up @@ -41,3 +42,41 @@ describe("buildThreadPersistenceMenuItems", () => {
expect(persistenceIntentForMenuEvent("archive")).toBeNull();
});
});

describe("thread menu dividers", () => {
const actions = [
{ id: "new-thread-on-branch", title: "New thread on feature" },
{ id: "annotate", title: "Annotate thread" },
{ id: "mark-persistent", title: "Mark as persistent thread" },
{ id: "stop-thread-processes", title: "Stop all previews & processes" },
{ id: "archive", title: "Archive" },
];

it("keeps persistence and stop together between inline native dividers", () => {
const groups = withThreadMenuDividers(actions, true);
expect(groups.map((group) => group.subactions?.map((action) => action.id))).toEqual([
["new-thread-on-branch"],
["annotate"],
["mark-persistent", "stop-thread-processes"],
["archive"],
]);
expect(groups.every((group) => group.displayInline === true)).toBe(true);
});

it("ends the persistence group when there is nothing to stop", () => {
const groups = withThreadMenuDividers(
actions.filter((action) => action.id !== "stop-thread-processes"),
true,
);
expect(groups.map((group) => group.subactions?.map((action) => action.id))).toEqual([
["new-thread-on-branch"],
["annotate"],
["mark-persistent"],
["archive"],
]);
});

it("keeps commands directly accessible when inline groups are unsupported", () => {
expect(withThreadMenuDividers(actions, false)).toEqual(actions);
});
});
Loading