Merge sai changes with integration fixes
This commit is contained in:
@@ -43,6 +43,9 @@ export const makeAgentOsRuntime = Effect.gen(function* () {
|
||||
catch: (cause) =>
|
||||
new AgentOsCommandError({ method: command.method, cause }),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
result instanceof Uint8Array ? result.slice().buffer : result
|
||||
),
|
||||
Effect.tap((result) =>
|
||||
controlPlane.recordEvent({
|
||||
commandId: command._id,
|
||||
|
||||
5
apps/native/app/(auth)/_layout.tsx
Normal file
5
apps/native/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
24
apps/native/app/(auth)/login.tsx
Normal file
24
apps/native/app/(auth)/login.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NativeLoginForm } from "@code/auth/native";
|
||||
import { router } from "expo-router";
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
|
||||
|
||||
export default function LoginScreen() {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1 bg-background"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerClassName="flex-grow justify-center p-6"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="mx-auto w-full max-w-md">
|
||||
<NativeLoginForm
|
||||
onNavigateSignUp={() => router.push("/(auth)/signup")}
|
||||
onSuccess={() => router.replace("/")}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
24
apps/native/app/(auth)/signup.tsx
Normal file
24
apps/native/app/(auth)/signup.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NativeSignupForm } from "@code/auth/native";
|
||||
import { router } from "expo-router";
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
|
||||
|
||||
export default function SignupScreen() {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
className="flex-1 bg-background"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerClassName="flex-grow justify-center p-6"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="mx-auto w-full max-w-md py-6">
|
||||
<NativeSignupForm
|
||||
onNavigateSignIn={() => router.push("/(auth)/login")}
|
||||
onSuccess={() => router.replace("/")}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +1,53 @@
|
||||
import { authClient } from "@code/auth/native";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { Button, Chip, Separator, Spinner, Surface, useThemeColor } from "heroui-native";
|
||||
import { useQuery } from "convex/react";
|
||||
import { Button, Spinner, Surface } from "heroui-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
import { SignIn } from "@/components/sign-in";
|
||||
import { SignUp } from "@/components/sign-up";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function Home() {
|
||||
const healthCheck = useQuery(api.healthCheck.get);
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const user = useQuery(api.auth.getCurrentUser, isAuthenticated ? {} : "skip");
|
||||
const successColor = useThemeColor("success");
|
||||
const dangerColor = useThemeColor("danger");
|
||||
|
||||
const isConnected = healthCheck === "OK";
|
||||
const isLoading = healthCheck === undefined;
|
||||
const user = useQuery(api.auth.getCurrentUser);
|
||||
|
||||
return (
|
||||
<Container className="px-4 pb-4">
|
||||
<View className="py-6 mb-5">
|
||||
<Text className="text-3xl font-semibold text-foreground tracking-tight">
|
||||
Better T Stack
|
||||
</Text>
|
||||
<Text className="text-muted text-sm mt-1">Full-stack TypeScript starter</Text>
|
||||
<View className="mb-5 py-6">
|
||||
<Text className="text-3xl font-semibold tracking-tight text-foreground">Zopu</Text>
|
||||
<Text className="mt-1 text-sm text-muted">Your authenticated workspace</Text>
|
||||
</View>
|
||||
|
||||
{user ? (
|
||||
<Surface variant="secondary" className="mb-4 p-4 rounded-xl">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Surface className="mb-4 rounded-xl p-4" variant="secondary">
|
||||
{user === undefined ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<View className="flex-row items-center justify-between gap-4">
|
||||
<View className="flex-1">
|
||||
<Text className="text-foreground font-medium">{user.name}</Text>
|
||||
<Text className="text-muted text-xs mt-0.5">{user.email}</Text>
|
||||
<Text className="font-medium text-foreground">{user?.name ?? "Signed in"}</Text>
|
||||
<Text className="mt-0.5 text-xs text-muted">{user?.email}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
authClient.signOut();
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
<Button onPress={() => void authClient.signOut()} size="sm" variant="danger">
|
||||
<Button.Label>Sign out</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</Surface>
|
||||
) : null}
|
||||
<Surface variant="secondary" className="p-4 rounded-xl">
|
||||
<Text className="text-foreground font-medium mb-2">API Status</Text>
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<Surface className="rounded-xl p-4" variant="secondary">
|
||||
<Text className="mb-2 font-medium text-foreground">API status</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View
|
||||
className={`w-2 h-2 rounded-full ${healthCheck === "OK" ? "bg-success" : "bg-danger"}`}
|
||||
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-success" : "bg-danger"}`}
|
||||
/>
|
||||
<Text className="text-muted text-xs">
|
||||
<Text className="text-xs text-muted">
|
||||
{healthCheck === undefined
|
||||
? "Checking..."
|
||||
? "Checking…"
|
||||
: healthCheck === "OK"
|
||||
? "Connected to API"
|
||||
: "API Disconnected"}
|
||||
? "Connected to Convex"
|
||||
: "Convex unavailable"}
|
||||
</Text>
|
||||
</View>
|
||||
</Surface>
|
||||
{!user && (
|
||||
<View className="mt-5 gap-4">
|
||||
<SignIn />
|
||||
<SignUp />
|
||||
</View>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,53 @@
|
||||
import "@/global.css";
|
||||
import { env } from "@code/env/native";
|
||||
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import { NativeAuthProvider } from "@code/auth/native";
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { Stack } from "expo-router";
|
||||
import { HeroUINativeProvider } from "heroui-native";
|
||||
import { HeroUINativeProvider, Spinner } from "heroui-native";
|
||||
import { View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
|
||||
import { AppThemeProvider } from "@/contexts/app-theme-context";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: "(drawer)",
|
||||
};
|
||||
function RootNavigator() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
|
||||
const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
|
||||
unsavedChangesWarning: false,
|
||||
});
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-background">
|
||||
<Spinner size="lg" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{}}>
|
||||
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ title: "Modal", presentation: "modal" }} />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Protected guard={!isAuthenticated}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={isAuthenticated}>
|
||||
<Stack.Screen name="(drawer)" />
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ headerShown: true, presentation: "modal", title: "Modal" }}
|
||||
/>
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
|
||||
<NativeAuthProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
<AppThemeProvider>
|
||||
<HeroUINativeProvider>
|
||||
<StackLayout />
|
||||
<RootNavigator />
|
||||
</HeroUINativeProvider>
|
||||
</AppThemeProvider>
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
</ConvexBetterAuthProvider>
|
||||
</NativeAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useToast,
|
||||
} from "heroui-native";
|
||||
import { useRef } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const signInSchema = z.object({
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
|
||||
});
|
||||
|
||||
function getErrorMessage(error: unknown): string | null {
|
||||
if (!error) return null;
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (Array.isArray(error)) {
|
||||
for (const issue of error) {
|
||||
const message = getErrorMessage(issue);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const maybeError = error as { message?: unknown };
|
||||
if (typeof maybeError.message === "string") {
|
||||
return maybeError.message;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SignIn() {
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signInSchema,
|
||||
},
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
toast.show({
|
||||
variant: "danger",
|
||||
label: error.error?.message || "Failed to sign in",
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
formApi.reset();
|
||||
toast.show({
|
||||
variant: "success",
|
||||
label: "Signed in successfully",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Surface variant="secondary" className="p-4 rounded-lg">
|
||||
<Text className="text-foreground font-medium mb-4">Sign In</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => {
|
||||
const formError = validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldError isInvalid={!!formError} className="mb-3">
|
||||
{formError}
|
||||
</FieldError>
|
||||
|
||||
<View className="gap-3">
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
textContentType="password"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" color="default" />
|
||||
) : (
|
||||
<Button.Label>Sign In</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useToast,
|
||||
} from "heroui-native";
|
||||
import { useRef } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const signUpSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
|
||||
});
|
||||
|
||||
function getErrorMessage(error: unknown): string | null {
|
||||
if (!error) return null;
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (Array.isArray(error)) {
|
||||
for (const issue of error) {
|
||||
const message = getErrorMessage(issue);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const maybeError = error as { message?: unknown };
|
||||
if (typeof maybeError.message === "string") {
|
||||
return maybeError.message;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SignUp() {
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signUpSchema,
|
||||
},
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
name: value.name.trim(),
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
toast.show({
|
||||
variant: "danger",
|
||||
label: error.error?.message || "Failed to sign up",
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
formApi.reset();
|
||||
toast.show({
|
||||
variant: "success",
|
||||
label: "Account created successfully",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Surface variant="secondary" className="p-4 rounded-lg">
|
||||
<Text className="text-foreground font-medium mb-4">Create Account</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => {
|
||||
const formError = validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldError isInvalid={!!formError} className="mb-3">
|
||||
{formError}
|
||||
</FieldError>
|
||||
|
||||
<View className="gap-3">
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="John Doe"
|
||||
autoComplete="name"
|
||||
textContentType="name"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
emailInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
ref={emailInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
textContentType="newPassword"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" color="default" />
|
||||
) : (
|
||||
<Button.Label>Create Account</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { env } from "@code/env/native";
|
||||
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import Constants from "expo-constants";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.EXPO_PUBLIC_CONVEX_SITE_URL,
|
||||
plugins: [
|
||||
convexClient(),
|
||||
Platform.OS === "web"
|
||||
? crossDomainClient()
|
||||
: expoClient({
|
||||
scheme: Constants.expoConfig?.scheme as string,
|
||||
storagePrefix: Constants.expoConfig?.scheme as string,
|
||||
storage: SecureStore,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -12,24 +12,18 @@
|
||||
"web": "bun --env-file=../../.env expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@code/auth": "workspace:*",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@convex-dev/better-auth": "catalog:",
|
||||
"@expo/metro-runtime": "~57.0.2",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"expo": "~57.0.1",
|
||||
"expo-constants": "~57.0.2",
|
||||
"expo-font": "~57.0.0",
|
||||
"expo-haptics": "~57.0.0",
|
||||
"expo-linking": "~57.0.1",
|
||||
"expo-network": "~57.0.0",
|
||||
"expo-router": "~57.0.2",
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
"expo-web-browser": "~57.0.0",
|
||||
"heroui-native": "^1.0.5",
|
||||
@@ -47,8 +41,7 @@
|
||||
"tailwind-merge": "catalog:",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "catalog:",
|
||||
"uniwind": "^1.10.0",
|
||||
"zod": "catalog:"
|
||||
"uniwind": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
|
||||
3
apps/web/.gitignore
vendored
3
apps/web/.gitignore
vendored
@@ -40,7 +40,8 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
*.log*
|
||||
*.log
|
||||
*.log.*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -10,17 +10,15 @@
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@code/auth": "workspace:*",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@code/ui": "workspace:*",
|
||||
"@convex-dev/better-auth": "catalog:",
|
||||
"@flue/react": "1.0.0-beta.9",
|
||||
"@flue/sdk": "1.0.0-beta.9",
|
||||
"@react-router/fs-routes": "^8.1.0",
|
||||
"@react-router/node": "^8.1.0",
|
||||
"@react-router/serve": "^8.1.0",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"isbot": "^5.1.44",
|
||||
"lucide-react": "catalog:",
|
||||
@@ -29,8 +27,7 @@
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router": "^8.1.0",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "2.5.0",
|
||||
"zod": "catalog:"
|
||||
"streamdown": "2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
|
||||
445
apps/web/src/components/projects/project-workspace-page.tsx
Normal file
445
apps/web/src/components/projects/project-workspace-page.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { Input } from "@code/ui/components/input";
|
||||
import { Textarea } from "@code/ui/components/textarea";
|
||||
import {
|
||||
Bot,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitFork,
|
||||
GitPullRequest,
|
||||
Play,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
|
||||
import UserMenu from "@/components/user-menu";
|
||||
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
|
||||
|
||||
const statusStyle: Record<Doc<"projectIssues">["status"], string> = {
|
||||
completed:
|
||||
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
failed: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
"needs-input":
|
||||
"border-orange-500/40 bg-orange-500/10 text-orange-700 dark:text-orange-300",
|
||||
open: "border-border text-muted-foreground",
|
||||
queued:
|
||||
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
working: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
};
|
||||
|
||||
interface RepositoryFormProps {
|
||||
readonly busy: boolean;
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly onSubmit: () => Promise<void>;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
const RepositoryForm = ({
|
||||
busy,
|
||||
onChange,
|
||||
onSubmit,
|
||||
value,
|
||||
}: RepositoryFormProps) => (
|
||||
<form
|
||||
className="grid gap-3 border-b p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium" htmlFor="github-repository">
|
||||
Public GitHub repository
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect with an owner/name slug or repository URL.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="github-repository"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="rivet-dev/rivet"
|
||||
required
|
||||
value={value}
|
||||
/>
|
||||
<Button disabled={busy || value.trim().length === 0} type="submit">
|
||||
<GitFork data-icon="inline-start" />
|
||||
{busy ? "Connecting" : "Connect repository"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
interface ProjectListProps {
|
||||
readonly projects: readonly Doc<"projects">[] | undefined;
|
||||
readonly selectedId: Id<"projects"> | null;
|
||||
readonly onSelect: (projectId: Id<"projects">) => void;
|
||||
}
|
||||
|
||||
const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
|
||||
const renderContent = () => {
|
||||
if (projects === undefined) {
|
||||
return (
|
||||
<p className="p-3 text-xs text-muted-foreground">Loading projects...</p>
|
||||
);
|
||||
}
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<p className="p-3 text-xs leading-relaxed text-muted-foreground">
|
||||
Connect a repository to create its project artifacts and issue queue.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
className={`w-full border px-3 py-2 text-left transition-colors ${
|
||||
selectedId === project._id
|
||||
? "border-foreground/30 bg-muted"
|
||||
: "border-transparent hover:bg-muted/60"
|
||||
}`}
|
||||
key={project._id}
|
||||
onClick={() => onSelect(project._id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{project.repoOwner}/{project.repoName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Connected projects"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-2"
|
||||
>
|
||||
{renderContent()}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
interface ArtifactGridProps {
|
||||
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
|
||||
}
|
||||
|
||||
const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
|
||||
<section aria-labelledby="artifact-heading" className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" id="artifact-heading">
|
||||
Project artifacts
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Canonical context staged into every issue workspace.
|
||||
</p>
|
||||
</div>
|
||||
{artifacts === undefined ? (
|
||||
<p className="text-sm text-muted-foreground">Loading artifacts...</p>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{artifacts.map((artifact) => (
|
||||
<article className="min-w-0 border bg-card p-3" key={artifact._id}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<h3 className="truncate font-mono text-xs font-medium">
|
||||
{artifact.path}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
r{artifact.revision}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-4 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
|
||||
{artifact.content}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
interface IssueComposerProps {
|
||||
readonly body: string;
|
||||
readonly busy: boolean;
|
||||
readonly onBodyChange: (value: string) => void;
|
||||
readonly onSubmit: () => Promise<void>;
|
||||
readonly onTitleChange: (value: string) => void;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
const IssueComposer = ({
|
||||
body,
|
||||
busy,
|
||||
onBodyChange,
|
||||
onSubmit,
|
||||
onTitleChange,
|
||||
title,
|
||||
}: IssueComposerProps) => (
|
||||
<form
|
||||
className="grid gap-3 border p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-semibold">Raise an issue</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The issue becomes the identity for one Flue agent and AgentOS workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="issue-title">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="issue-title"
|
||||
maxLength={160}
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
required
|
||||
value={title}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="issue-body">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="issue-body"
|
||||
maxLength={10_000}
|
||||
onChange={(event) => onBodyChange(event.target.value)}
|
||||
required
|
||||
rows={5}
|
||||
value={body}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busy || title.trim().length < 3 || body.trim().length < 10}
|
||||
type="submit"
|
||||
>
|
||||
<Plus data-icon="inline-start" />
|
||||
{busy ? "Creating" : "Create issue"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
interface IssueListProps {
|
||||
readonly issues: readonly Doc<"projectIssues">[] | undefined;
|
||||
readonly pendingAction: string | null;
|
||||
readonly onStart: (
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
title: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => {
|
||||
const renderContent = () => {
|
||||
if (issues === undefined) {
|
||||
return <p className="text-sm text-muted-foreground">Loading issues...</p>;
|
||||
}
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<p className="border p-4 text-sm text-muted-foreground">
|
||||
No issues yet. Describe a concrete change to start the workflow.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{issues.map((issue) => {
|
||||
const canStart = ["open", "needs-input", "failed"].includes(
|
||||
issue.status
|
||||
);
|
||||
const busy = pendingAction === `issue:${issue._id}`;
|
||||
let actionLabel: string = issue.status;
|
||||
if (canStart) {
|
||||
actionLabel = "Start agent";
|
||||
}
|
||||
if (busy) {
|
||||
actionLabel = "Dispatching";
|
||||
}
|
||||
return (
|
||||
<article
|
||||
className="grid gap-3 border bg-card p-4 md:grid-cols-[1fr_auto] md:items-center"
|
||||
key={issue._id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
#{issue.number}
|
||||
</span>
|
||||
<span
|
||||
className={`border px-1.5 py-0.5 text-[11px] font-medium ${statusStyle[issue.status]}`}
|
||||
>
|
||||
{issue.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-medium">{issue.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{issue.body}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!canStart || busy}
|
||||
onClick={() =>
|
||||
void onStart(issue._id, issue.number, issue.title)
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={canStart ? "default" : "outline"}
|
||||
>
|
||||
{canStart ? (
|
||||
<Play data-icon="inline-start" />
|
||||
) : (
|
||||
<Bot data-icon="inline-start" />
|
||||
)}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="issue-heading" className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitPullRequest className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold" id="issue-heading">
|
||||
Issue queue
|
||||
</h2>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectWorkspacePage = () => {
|
||||
const workspace = useProjectWorkspace();
|
||||
const handleRepositoryChange = workspace.setRepository;
|
||||
const handleRepositorySubmit = workspace.connectRepository;
|
||||
const handleProjectSelect = workspace.setSelectedProjectId;
|
||||
const handleIssueBodyChange = workspace.setIssueBody;
|
||||
const handleIssueSubmit = workspace.raiseIssue;
|
||||
const handleIssueTitleChange = workspace.setIssueTitle;
|
||||
const handleIssueStart = workspace.startIssue;
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-background text-foreground">
|
||||
<header className="border-b">
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-4 py-3 md:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-8 place-items-center border bg-muted">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Project manager</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitHub, Flue, AgentOS
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto grid min-h-[calc(100svh-57px)] max-w-[1400px] md:grid-cols-[260px_1fr]">
|
||||
<aside className="flex min-h-0 flex-col border-b md:border-r md:border-b-0">
|
||||
<RepositoryForm
|
||||
busy={workspace.pendingAction === "connect"}
|
||||
onChange={handleRepositoryChange}
|
||||
onSubmit={handleRepositorySubmit}
|
||||
value={workspace.repository}
|
||||
/>
|
||||
<ProjectList
|
||||
onSelect={handleProjectSelect}
|
||||
projects={workspace.projects}
|
||||
selectedId={workspace.selectedProjectId}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 p-4 md:p-6 lg:p-8">
|
||||
{workspace.error ? (
|
||||
<div className="mb-4 border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{workspace.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{workspace.selectedProject ? (
|
||||
<div className="grid gap-8">
|
||||
<div className="flex flex-col gap-3 border-b pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connected GitHub project
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{workspace.selectedProject.repoOwner}/
|
||||
{workspace.selectedProject.repoName}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{workspace.selectedProject.description ??
|
||||
"No repository description is available."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
workspace.selectedProject?.repoUrl,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
Open GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ArtifactGrid artifacts={workspace.artifacts} />
|
||||
|
||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(280px,380px)_1fr]">
|
||||
<IssueComposer
|
||||
body={workspace.issueBody}
|
||||
busy={workspace.pendingAction === "issue"}
|
||||
onBodyChange={handleIssueBodyChange}
|
||||
onSubmit={handleIssueSubmit}
|
||||
onTitleChange={handleIssueTitleChange}
|
||||
title={workspace.issueTitle}
|
||||
/>
|
||||
<IssueList
|
||||
issues={workspace.issues}
|
||||
onStart={handleIssueStart}
|
||||
pendingAction={workspace.pendingAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-[60svh] place-items-center text-center">
|
||||
<div className="max-w-sm space-y-2">
|
||||
<GitFork className="mx-auto size-7 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">
|
||||
Connect your first repository
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zopu creates the project artifact set and opens an issue queue
|
||||
for agent work.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { Input } from "@code/ui/components/input";
|
||||
import { Label } from "@code/ui/components/label";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate("/dashboard");
|
||||
toast.success("Sign in successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-10 w-full max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error, index) => (
|
||||
<p key={`${field.name}-error-${index}`} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error, index) => (
|
||||
<p key={`${field.name}-error-${index}`} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
canSubmit: state.canSubmit,
|
||||
isSubmitting: state.isSubmitting,
|
||||
})}
|
||||
>
|
||||
{({ canSubmit, isSubmitting }) => (
|
||||
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Sign In"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { Input } from "@code/ui/components/input";
|
||||
import { Label } from "@code/ui/components/label";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate("/dashboard");
|
||||
toast.success("Sign up successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-10 w-full max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error, index) => (
|
||||
<p key={`${field.name}-error-${index}`} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error, index) => (
|
||||
<p key={`${field.name}-error-${index}`} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error, index) => (
|
||||
<p key={`${field.name}-error-${index}`} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
canSubmit: state.canSubmit,
|
||||
isSubmitting: state.isSubmitting,
|
||||
})}
|
||||
>
|
||||
{({ canSubmit, isSubmitting }) => (
|
||||
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
@@ -12,15 +13,15 @@ import {
|
||||
import { useQuery } from "convex/react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const user = useQuery(api.auth.getCurrentUser);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>{user?.name}</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
||||
{user?.name}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-card">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
@@ -32,7 +33,7 @@ export default function UserMenu() {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate("/dashboard");
|
||||
navigate("/login", { replace: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
113
apps/web/src/hooks/use-project-workspace.ts
Normal file
113
apps/web/src/hooks/use-project-workspace.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { flueClient } from "@/root";
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export const useProjectWorkspace = () => {
|
||||
const projects = useQuery(api.projects.list);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [issueTitle, setIssueTitle] = useState("");
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const connectGitHub = useAction(api.projects.connectGitHub);
|
||||
const createIssue = useMutation(api.projectIssues.create);
|
||||
const beginIssue = useMutation(api.projectIssues.begin);
|
||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
||||
const activeProjectId = selectedProjectId ?? projects?.[0]?._id ?? null;
|
||||
const artifacts = useQuery(
|
||||
api.projectArtifacts.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const issues = useQuery(
|
||||
api.projectIssues.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
|
||||
const connectRepository = async () => {
|
||||
setPendingAction("connect");
|
||||
setError(null);
|
||||
try {
|
||||
const projectId = await connectGitHub({ repository });
|
||||
setSelectedProjectId(projectId);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssue = async () => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
setPendingAction("issue");
|
||||
setError(null);
|
||||
try {
|
||||
await createIssue({
|
||||
body: issueBody,
|
||||
projectId: activeProjectId,
|
||||
title: issueTitle,
|
||||
});
|
||||
setIssueTitle("");
|
||||
setIssueBody("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startIssue = async (
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
title: string
|
||||
) => {
|
||||
const actionKey = `issue:${issueId}`;
|
||||
setPendingAction(actionKey);
|
||||
setError(null);
|
||||
try {
|
||||
await beginIssue({ issueId });
|
||||
await flueClient.agents.send("project-manager", String(issueId), {
|
||||
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = errorMessage(caughtError);
|
||||
await markDispatchFailed({ error: message, issueId });
|
||||
setError(message);
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProject =
|
||||
projects?.find((project) => project._id === activeProjectId) ?? null;
|
||||
|
||||
return {
|
||||
artifacts,
|
||||
connectRepository,
|
||||
error,
|
||||
issueBody,
|
||||
issueTitle,
|
||||
issues,
|
||||
pendingAction,
|
||||
projects,
|
||||
raiseIssue,
|
||||
repository,
|
||||
selectedProject,
|
||||
selectedProjectId: activeProjectId,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
setRepository,
|
||||
setSelectedProjectId,
|
||||
startIssue,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.VITE_CONVEX_SITE_URL,
|
||||
plugins: [convexClient(), crossDomainClient()],
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { WebAuthProvider } from "@code/auth/web";
|
||||
import { env } from "@code/env/web";
|
||||
import { Toaster } from "@code/ui/components/sonner";
|
||||
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
||||
import { FlueProvider } from "@flue/react";
|
||||
|
||||
import "./index.css";
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
@@ -15,8 +14,6 @@ import {
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
|
||||
@@ -67,7 +64,7 @@ const flueFetch: typeof fetch = async (input, init) => {
|
||||
}
|
||||
);
|
||||
};
|
||||
const flueClient = createFlueClient({
|
||||
export const flueClient = createFlueClient({
|
||||
baseUrl: flueBaseUrl.toString(),
|
||||
fetch: flueFetch,
|
||||
});
|
||||
@@ -87,26 +84,23 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
</html>
|
||||
);
|
||||
|
||||
const App = () => {
|
||||
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
|
||||
return (
|
||||
<FlueProvider client={flueClient}>
|
||||
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<div className="h-svh min-h-0 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</ConvexBetterAuthProvider>
|
||||
</FlueProvider>
|
||||
);
|
||||
};
|
||||
const App = () => (
|
||||
<FlueProvider client={flueClient}>
|
||||
<WebAuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<div className="h-svh min-h-0 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</WebAuthProvider>
|
||||
</FlueProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChatPage } from "@/components/chat/chat-page";
|
||||
|
||||
import type { Route } from "./+types/_index";
|
||||
import type { Route } from "./+types/_app._index";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Zopu" },
|
||||
5
apps/web/src/routes/_app.dashboard.tsx
Normal file
5
apps/web/src/routes/_app.dashboard.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProjectWorkspacePage } from "@/components/projects/project-workspace-page";
|
||||
|
||||
export default function Dashboard() {
|
||||
return <ProjectWorkspacePage />;
|
||||
}
|
||||
17
apps/web/src/routes/_app.tsx
Normal file
17
apps/web/src/routes/_app.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
|
||||
export default function AppLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
15
apps/web/src/routes/_auth.login.tsx
Normal file
15
apps/web/src/routes/_auth.login.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { LoginForm } from "@code/auth/web";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { Route } from "./+types/_auth.login";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Sign in | Zopu" },
|
||||
{ content: "Sign in to Zopu", name: "description" },
|
||||
];
|
||||
|
||||
export default function LoginRoute() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />;
|
||||
}
|
||||
15
apps/web/src/routes/_auth.signup.tsx
Normal file
15
apps/web/src/routes/_auth.signup.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SignupForm } from "@code/auth/web";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { Route } from "./+types/_auth.signup";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Create account | Zopu" },
|
||||
{ content: "Create your Zopu account", name: "description" },
|
||||
];
|
||||
|
||||
export default function SignupRoute() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <SignupForm onSuccess={() => navigate("/", { replace: true })} />;
|
||||
}
|
||||
20
apps/web/src/routes/_auth.tsx
Normal file
20
apps/web/src/routes/_auth.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
if (isAuthenticated) return <Navigate replace to="/" />;
|
||||
|
||||
return (
|
||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { Authenticated, AuthLoading, Unauthenticated, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import SignInForm from "@/components/sign-in-form";
|
||||
import SignUpForm from "@/components/sign-up-form";
|
||||
import UserMenu from "@/components/user-menu";
|
||||
|
||||
function PrivateDashboardContent() {
|
||||
const privateData = useQuery(api.privateData.get);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>privateData: {privateData?.message}</p>
|
||||
<UserMenu />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Authenticated>
|
||||
<PrivateDashboardContent />
|
||||
</Authenticated>
|
||||
<Unauthenticated>
|
||||
{showSignIn ? (
|
||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
||||
) : (
|
||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
||||
)}
|
||||
</Unauthenticated>
|
||||
<AuthLoading>
|
||||
<div>Loading...</div>
|
||||
</AuthLoading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user