Merge branch 'storage-terminal-ui-dev' into dev

# Conflicts:
#	nix/package.nix
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/hooks/use-command-center.ts
#	packages/app/src/screens/agent/draft-agent-screen.tsx
#	packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/terminal/terminal.test.ts
#	packages/server/src/terminal/terminal.ts
This commit is contained in:
Mohamed Boudra
2026-04-01 21:51:29 +07:00
217 changed files with 21075 additions and 4943 deletions
+30 -2
View File
@@ -15,7 +15,10 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { Archive, SquareTerminal } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
@@ -130,6 +133,7 @@ function SessionRow({
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
return (
<Pressable
@@ -145,12 +149,21 @@ function SessionRow({
>
<View style={styles.rowContent}>
<View style={styles.rowTitleRow}>
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
{agent.title || "New session"}
</Text>
{agent.terminal ? (
<SessionBadge
label="Terminal"
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{agent.archivedAt ? (
<SessionBadge
label="Archived"
@@ -231,14 +244,24 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
workspaceDirectory: agent.cwd,
});
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return;
}
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
workspaceId,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
requestReopen: agent.terminal && agent.status === "closed",
});
router.navigate(route as any);
},
@@ -433,6 +456,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerIconWrap: {
width: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
rowMetaRow: {
flexDirection: "row",
alignItems: "center",
@@ -63,6 +63,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
@@ -132,10 +133,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
workspaceDirectory: workspaceRoot,
});
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceId: workspaceId ?? undefined,
workspaceRoot,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
@@ -175,12 +179,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
if (workspaceId) {
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
}
return;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
import { resolveStatusControlMode } from "./composer.status-controls";
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
@@ -41,7 +41,7 @@ import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -56,11 +56,12 @@ type QueuedMessage = {
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
interface AgentInputAreaProps {
interface ComposerProps {
agentId: string;
serverId: string;
isInputActive: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
allowEmptySubmit?: boolean;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
/** When true, blurs the input immediately when submitting. */
@@ -89,11 +90,12 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
export function Composer({
agentId,
serverId,
isInputActive,
onSubmitMessage,
allowEmptySubmit = false,
isSubmitLoading = false,
blurOnSubmit = false,
value,
@@ -109,8 +111,8 @@ export function AgentInputArea({
onAttentionInputFocus,
onAttentionPromptSend,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
@@ -480,7 +482,7 @@ export function AgentInputArea({
return;
}
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[AgentInputArea] Failed to start voice mode", error);
console.error("[Composer] Failed to start voice mode", error);
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : null;
if (message && message.trim().length > 0) {
@@ -677,6 +679,7 @@ export function AgentInputArea({
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
allowEmptySubmit={allowEmptySubmit}
isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading}
images={selectedImages}
@@ -0,0 +1,16 @@
import Svg, { Path } from "react-native-svg";
interface AiderIconProps {
size?: number;
color?: string;
}
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
/>
</Svg>
);
}
@@ -0,0 +1,29 @@
import Svg, { Path } from "react-native-svg";
interface AmpIconProps {
size?: number;
color?: string;
}
export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
fill={color}
d="m9.686 6.949 2.907.775-1.98-7.404-3.353.903 1.194 4.49a1.94 1.94 0 0 0 1.232 1.236Z"
/>
<Path
fill={color}
d="m4.771 22 6.34-6.327 2.307 8.62 3.352-.903L13.432 10.87.912 7.533 0 10.906l8.61 2.3-6.31 6.317L4.77 22Z"
/>
<Path
fill={color}
d="m13.254 11.707.778 2.917a1.937 1.937 0 0 0 1.23 1.234l4.511 1.199.89-3.37-7.409-1.98Z"
/>
<Path
fill={color}
d="m15.916 2.484-2.883 2.88a2.063 2.063 0 0 0-.512 1.193l-.046 1.825 1.69.06-.022-.001c.463 0 .898-.181 1.225-.507L18.35 4.95l-2.434-2.467Z"
/>
</Svg>
);
}
@@ -0,0 +1,17 @@
import Svg, { Path } from "react-native-svg";
interface GeminiIconProps {
size?: number;
color?: string;
}
export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
fill={color}
d="M20.616 10.835a14.147 14.147 0 0 1-4.45-3.001 14.111 14.111 0 0 1-3.678-6.452.503.503 0 0 0-.975 0 14.134 14.134 0 0 1-3.679 6.452 14.155 14.155 0 0 1-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 0 0 0 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 0 1 4.45 3.001 14.112 14.112 0 0 1 3.679 6.453.502.502 0 0 0 .975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 0 1 3.001-4.45 14.113 14.113 0 0 1 6.453-3.678.503.503 0 0 0 0-.975 13.245 13.245 0 0 1-2.003-.678Z"
/>
</Svg>
);
}
@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
interface OpenCodeIconProps {
size?: number;
color?: string;
}
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
return (
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
<Path d="M320 224V352H192V224H320Z" opacity="0.4" />
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
/>
</Svg>
);
}
@@ -53,6 +53,7 @@ export interface MessageInputProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
allowEmptySubmit?: boolean;
isSubmitDisabled?: boolean;
isSubmitLoading?: boolean;
images?: ImageAttachment[];
@@ -178,6 +179,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
value,
onChangeText,
onSubmit,
allowEmptySubmit = false,
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
@@ -851,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
@@ -40,9 +40,9 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id,
);
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}, [workspaces]);
const directorySuggestionsQuery = useQuery({
@@ -1,10 +1,18 @@
import { Bot } from "lucide-react-native";
import { AiderIcon } from "@/components/icons/aider-icon";
import { AmpIcon } from "@/components/icons/amp-icon";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { GeminiIcon } from "@/components/icons/gemini-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
gemini: GeminiIcon as unknown as typeof Bot,
amp: AmpIcon as unknown as typeof Bot,
aider: AiderIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
};
export function getProviderIcon(provider: string): typeof Bot {
@@ -10,7 +10,7 @@ import {
type GestureResponderEvent,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useMutation, useQueries } from "@tanstack/react-query";
import { useQueries } from "@tanstack/react-query";
import {
useCallback,
useMemo,
@@ -45,7 +45,6 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -83,10 +82,14 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { createNameId } from "mnemonic-id";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -239,7 +242,7 @@ function WorkspaceStatusIndicator({
}
const KindIcon =
workspaceKind === "local_checkout"
workspaceKind === "checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
@@ -654,7 +657,6 @@ function ProjectHeaderRow({
canCreateWorktree,
isProjectActive = false,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber = null,
showShortcutBadge = false,
drag,
@@ -665,50 +667,30 @@ function ProjectHeaderRow({
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = isCompactFormFactor();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast();
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
const handleBeginWorkspaceSetup = useCallback(() => {
if (!serverId) {
return;
}
onWorkspacePress?.();
beginWorkspaceSetup({
serverId,
sourceDirectory: project.iconWorkingDir,
displayName,
creationMethod: "create_worktree",
navigationMethod: "navigate",
});
}, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const createWorktreeMutation = useMutation({
mutationFn: async () => {
if (!serverId) {
throw new Error("No server");
}
const client = getHostRuntimeStore().getClient(serverId);
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
throw new Error("Host is not connected");
}
const payload = await client.createPaseoWorktree({
cwd: project.iconWorkingDir,
worktreeSlug: createNameId(),
});
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "Failed to create worktree");
}
return payload.workspace;
},
onSuccess: (workspace) => {
mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]);
onWorktreeCreated?.(workspace.id);
onWorkspacePress?.();
router.navigate(
prepareWorkspaceTab({
serverId: serverId!,
workspaceId: workspace.id,
target: { kind: "draft", draftId: "new" },
}) as any,
);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : String(error));
},
});
useKeyboardActionHandler({
handlerId: `worktree-new-${project.projectKey}`,
actions: ["worktree.new"],
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
enabled: isProjectActive && canCreateWorktree,
priority: 0,
handle: () => {
createWorktreeMutation.mutate();
handleBeginWorkspaceSetup();
return true;
},
});
@@ -752,9 +734,9 @@ function ProjectHeaderRow({
{canCreateWorktree ? (
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
onPress={handleBeginWorkspaceSetup}
visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
loading={false}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
@@ -836,10 +818,13 @@ function WorkspaceRowInner({
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
enabled: workspace.workspaceKind !== "directory",
cwd: workspaceDirectory ?? "",
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
});
const interaction = useLongPressDragInteraction({
drag,
@@ -1003,12 +988,17 @@ function WorkspaceRowWithMenu({
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
actionId: "archive-worktree",
}),
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
@@ -1046,11 +1036,26 @@ function WorkspaceRowWithMenu({
if (!confirmed) {
return;
}
let workspaceDirectory: string;
try {
workspaceDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!workspaceDirectory) {
toast.error("Workspace path not available");
return;
}
void archiveWorktree({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
worktreePath: workspace.workspaceId,
cwd: workspaceDirectory,
worktreePath: workspaceDirectory,
})
.then(() => {
redirectAfterArchive();
@@ -1066,6 +1071,7 @@ function WorkspaceRowWithMenu({
redirectAfterArchive,
toast,
workspace.name,
workspace.workspaceDirectory,
workspace.serverId,
workspace.workspaceId,
]);
@@ -1095,7 +1101,7 @@ function WorkspaceRowWithMenu({
setIsArchivingWorkspace(true);
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
if (payload.error) {
throw new Error(payload.error);
}
@@ -1116,9 +1122,19 @@ function WorkspaceRowWithMenu({
]);
const handleCopyPath = useCallback(() => {
void Clipboard.setStringAsync(workspace.workspaceId);
let workspaceDirectory: string;
try {
workspaceDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(workspaceDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceId]);
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
@@ -1240,7 +1256,7 @@ function NonGitProjectRowWithMenuContent({
setIsArchivingWorkspace(true);
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
if (payload.error) {
throw new Error(payload.error);
}
@@ -1351,7 +1367,7 @@ function FlattenedProjectRow({
dragHandleProps?: DraggableListDragHandleProps;
isProjectActive?: boolean;
}) {
if (project.projectKind === "non_git") {
if (project.projectKind === "directory") {
return (
<NonGitProjectRowWithMenu
project={project}
@@ -87,12 +87,7 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: {
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
paneId?: string;
}) => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
onCreateLauncherTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
@@ -268,9 +263,7 @@ export function SplitContainer({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId = "__new_tab_agent__",
onCreateLauncherTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -537,9 +530,7 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateLauncherTab={onCreateLauncherTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -659,9 +650,7 @@ function SplitNodeView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
onCreateLauncherTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -694,9 +683,7 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateLauncherTab={onCreateLauncherTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -744,9 +731,7 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateLauncherTab={onCreateLauncherTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -793,9 +778,7 @@ function SplitPaneView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
onCreateLauncherTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -904,9 +887,7 @@ function SplitPaneView({
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onCreateLauncherTab={onCreateLauncherTab}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(
pane.id,
@@ -244,6 +244,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
);
useEffect(() => {
const currentPathname =
typeof window === "undefined" ? null : (window.location.pathname || null);
if (currentPathname && currentPathname !== "/welcome") {
return;
}
if (!anyOnlineServerId) {
return;
}
@@ -0,0 +1,726 @@
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
import { getProviderIcon } from "@/components/provider-icons";
import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import {
requireWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { MessagePayload } from "./message-input";
type SetupStep = "choose" | "chat" | "terminal-agent";
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [step, setStep] = useState<SetupStep>("choose");
const [terminalPrompt, setTerminalPrompt] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
null,
);
const serverId = pendingWorkspaceSetup?.serverId ?? "";
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
const workspace = createdWorkspace;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
composer: {
initialServerId: serverId || null,
initialValues: workspace?.workspaceDirectory
? { workingDir: workspace.workspaceDirectory }
: undefined,
isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspace?.workspaceDirectory || undefined,
},
});
const composerState = chatDraft.composerState;
if (!composerState && pendingWorkspaceSetup) {
throw new Error("Workspace setup composer state is required");
}
const { providers: sortedProviders, recordUsage } = useProviderRecency(
composerState?.providerDefinitions ?? [],
);
useEffect(() => {
setStep("choose");
setTerminalPrompt("");
setErrorMessage(null);
setCreatedWorkspace(null);
setPendingAction(null);
}, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]);
const handleClose = useCallback(() => {
clearWorkspaceSetup();
}, [clearWorkspaceSetup]);
const navigateAfterCreation = useCallback(
(
workspaceId: string,
target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string },
) => {
if (!pendingWorkspaceSetup) {
return;
}
clearWorkspaceSetup();
navigateToPreparedWorkspaceTab({
serverId: pendingWorkspaceSetup.serverId,
workspaceId,
target,
navigationMethod: pendingWorkspaceSetup.navigationMethod,
});
},
[clearWorkspaceSetup, pendingWorkspaceSetup],
);
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
throw new Error("Host is not connected");
}
return client;
}, [client, isConnected]);
const ensureWorkspace = useCallback(async () => {
if (!pendingWorkspaceSetup) {
throw new Error("No workspace setup is pending");
}
if (createdWorkspace) {
return createdWorkspace;
}
const connectedClient = withConnectedClient();
const payload =
pendingWorkspaceSetup.creationMethod === "create_worktree"
? await connectedClient.createPaseoWorktree({
cwd: pendingWorkspaceSetup.sourceDirectory,
worktreeSlug: createNameId(),
})
: await connectedClient.openProject(pendingWorkspaceSetup.sourceDirectory);
if (payload.error || !payload.workspace) {
throw new Error(
payload.error ??
(pendingWorkspaceSetup.creationMethod === "create_worktree"
? "Failed to create worktree"
: "Failed to open project"),
);
}
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
if (pendingWorkspaceSetup.creationMethod === "open_project") {
setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true);
}
setCreatedWorkspace(normalizedWorkspace);
return normalizedWorkspace;
}, [
createdWorkspace,
mergeWorkspaces,
pendingWorkspaceSetup,
setHasHydratedWorkspaces,
withConnectedClient,
]);
const getIsStillActive = useCallback(() => {
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
return (
current?.serverId === pendingWorkspaceSetup?.serverId &&
current?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory &&
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
);
}, [
pendingWorkspaceSetup?.creationMethod,
pendingWorkspaceSetup?.serverId,
pendingWorkspaceSetup?.sourceDirectory,
]);
const handleCreateChatAgent = useCallback(
async ({ text, images }: MessagePayload) => {
try {
setPendingAction("chat");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
}
const encodedImages = await encodeImages(images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id),
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}),
...(text.trim() ? { initialPrompt: text.trim() } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
});
if (!getIsStillActive()) {
return;
}
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
toast.error(message);
} finally {
if (getIsStillActive()) {
setPendingAction(null);
}
}
},
[
composerState,
getIsStillActive,
navigateAfterCreation,
serverId,
setAgents,
ensureWorkspace,
toast,
withConnectedClient,
],
);
const handleCreateTerminalAgent = useCallback(async () => {
try {
setPendingAction("terminal-agent");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
}
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id),
terminal: true,
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
});
if (!getIsStillActive()) {
return;
}
recordUsage(composerState.selectedProvider);
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
toast.error(message);
} finally {
if (getIsStillActive()) {
setPendingAction(null);
}
}
}, [
composerState,
getIsStillActive,
navigateAfterCreation,
recordUsage,
serverId,
setAgents,
ensureWorkspace,
terminalPrompt,
toast,
withConnectedClient,
]);
const handleCreateTerminal = useCallback(async () => {
try {
setPendingAction("terminal");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const payload = await connectedClient.createTerminal(workspaceDirectory);
if (payload.error || !payload.terminal) {
throw new Error(payload.error ?? "Failed to open terminal");
}
if (!getIsStillActive()) {
return;
}
navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
toast.error(message);
} finally {
if (getIsStillActive()) {
setPendingAction(null);
}
}
}, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]);
const workspaceTitle =
workspace?.name ||
workspace?.projectDisplayName ||
displayName ||
sourceDirectory.split(/[\\/]/).filter(Boolean).pop() ||
sourceDirectory;
const workspacePath = workspace?.workspaceDirectory || "Workspace will be created before launch.";
if (!pendingWorkspaceSetup || !sourceDirectory) {
return null;
}
return (
<AdaptiveModalSheet
title="Set up workspace"
visible={true}
onClose={handleClose}
snapPoints={["82%", "94%"]}
testID="workspace-setup-dialog"
>
<View style={styles.header}>
<Text style={styles.workspaceTitle}>{workspaceTitle}</Text>
<Text style={styles.workspacePath}>{workspacePath}</Text>
</View>
{step === "choose" ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>What do you want to open?</Text>
<View style={styles.choiceGrid}>
<ChoiceCard
title="Chat Agent"
description="Open this workspace with a prompt-first chat agent."
Icon={MessagesSquare}
disabled={pendingAction !== null}
onPress={() => {
setErrorMessage(null);
setStep("chat");
}}
/>
<ChoiceCard
title="Terminal Agent"
description="Launch an agent-backed terminal in an agent tab."
Icon={Bot}
disabled={pendingAction !== null}
onPress={() => {
setErrorMessage(null);
setStep("terminal-agent");
}}
/>
<ChoiceCard
title="Terminal"
description="Create the workspace, then open a standalone terminal tab."
Icon={SquareTerminal}
disabled={pendingAction !== null}
pending={pendingAction === "terminal"}
onPress={() => {
void handleCreateTerminal();
}}
/>
</View>
</View>
) : null}
{step === "chat" ? (
<View style={styles.section}>
<StepHeader
title="Chat Agent"
onBack={() => {
setErrorMessage(null);
setStep("choose");
}}
/>
<Text style={styles.helper}>
Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens.
</Text>
<View style={styles.composerCard}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isInputActive={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
images={chatDraft.images}
onChangeImages={chatDraft.setImages}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={
composerState
? {
...composerState.statusControls,
disabled: pendingAction !== null,
}
: undefined
}
/>
</View>
</View>
) : null}
{step === "terminal-agent" ? (
<View style={styles.section}>
<StepHeader
title="Terminal Agent"
onBack={() => {
setErrorMessage(null);
setStep("choose");
}}
/>
<Text style={styles.helper}>
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
</Text>
<View style={styles.providerGrid}>
{sortedProviders.map((provider) => (
<ProviderOption
key={provider.id}
provider={provider}
selected={provider.id === composerState?.selectedProvider}
disabled={pendingAction !== null}
onPress={() => composerState?.setProviderFromUser(provider.id)}
/>
))}
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Initial prompt</Text>
<AdaptiveTextInput
value={terminalPrompt}
onChangeText={setTerminalPrompt}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
multiline
autoCapitalize="sentences"
autoCorrect={false}
/>
</View>
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={handleClose}
>
Cancel
</Button>
<Button
variant="default"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={() => {
void handleCreateTerminalAgent();
}}
>
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
</Button>
</View>
</View>
) : null}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
);
}
function StepHeader({ title, onBack }: { title: string; onBack: () => void }) {
const { theme } = useUnistyles();
return (
<View style={styles.stepHeader}>
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
<ChevronLeft size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<Text style={styles.sectionTitle}>{title}</Text>
</View>
);
}
function ChoiceCard({
title,
description,
Icon,
disabled,
pending = false,
onPress,
}: {
title: string;
description: string;
Icon: ComponentType<{ size: number; color: string }>;
disabled: boolean;
pending?: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.choiceCard,
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
disabled ? styles.cardDisabled : null,
]}
>
<View style={styles.choiceIconWrap}>
{pending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Icon size={16} color={theme.colors.foreground} />
)}
</View>
<View style={styles.choiceBody}>
<Text style={styles.choiceTitle}>{title}</Text>
<Text numberOfLines={1} style={styles.choiceDescription}>{description}</Text>
</View>
</Pressable>
);
}
function ProviderOption({
provider,
selected,
disabled,
onPress,
}: {
provider: { id: AgentProvider; label: string; description: string };
selected: boolean;
disabled: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
const Icon = getProviderIcon(provider.id);
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerCard,
selected ? styles.providerCardSelected : null,
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
disabled ? styles.cardDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
<Icon size={16} color={theme.colors.foreground} />
</View>
<View style={styles.providerBody}>
<Text style={styles.providerTitle}>{provider.label}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
gap: theme.spacing[1],
},
workspaceTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
workspacePath: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[3],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
helper: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
lineHeight: 20,
},
choiceGrid: {
gap: theme.spacing[2],
},
choiceCard: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
choiceCardHovered: {
backgroundColor: theme.colors.surface2,
},
cardDisabled: {
opacity: theme.opacity[50],
},
choiceIconWrap: {
width: 32,
height: 32,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
choiceBody: {
flex: 1,
gap: 2,
},
choiceTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
choiceDescription: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
composerCard: {
minHeight: 180,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
stepHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
backButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerCard: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
providerCardSelected: {
borderColor: theme.colors.accent,
backgroundColor: theme.colors.surface2,
},
providerIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerBody: {
flex: 1,
},
providerTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
field: {
gap: theme.spacing[2],
},
fieldLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
input: {
minHeight: 80,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
textAlignVertical: "top",
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
},
actionButton: {
flex: 1,
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.destructive,
lineHeight: 20,
},
}));