Repository: canyongbs/advisingapp
Pull request #1548: [ADVAPP-1453]: Staff can organize saved research reports into simple top-level folders for faster retrieval
Author: login: danharrin
Labels: name: Change Type | New Feature
description: New feature or request
Created: 2025-05-16T16:58:17Z | Merged: 2025-05-20T18:15:49Z

### Ticket(s) or GitHub Issue

- https://canyongbs.atlassian.net/browse/ADVAPP-1453

### Technical Description

Introduces UI similar to assistants, where requests are managed in a folder interface and can be selected, renamed, deleted and moved.

### Any deployment steps required?

No

### Are any Feature Flags and/or Data Migrations that can eventually be removed Added?

No

_______________________________________________

#### Before contributing and submitting this PR, make sure you have Read, agree, and are compliant with the [contributing guidelines](https://github.com/canyongbs/advisingapp/blob/main/README.md#contributing).



Diff:
diff --git a/_ide_helper_models.php b/_ide_helper_models.php
index 8bf84ede0b..4c6a99f26f 100644
--- a/_ide_helper_models.php
+++ b/_ide_helper_models.php
@@ -654,6 +654,8 @@ class IdeHelperTenant {}
  * @property-read int|null $prospect_care_teams_count
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \AdvisingApp\Prospect\Models\Prospect> $prospectSubscriptions
  * @property-read int|null $prospect_subscriptions_count
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \AdvisingApp\Research\Models\ResearchRequestFolder> $researchRequestFolders
+ * @property-read int|null $research_request_folders_count
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \AdvisingApp\Research\Models\ResearchRequest> $researchRequests
  * @property-read int|null $research_requests_count
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \AdvisingApp\Authorization\Models\Role> $roles
@@ -4672,6 +4674,8 @@ class IdeHelperTrackedEventCount {}
  * @property \Illuminate\Support\Carbon|null $created_at
  * @property \Illuminate\Support\Carbon|null $updated_at
  * @property string|null $deleted_at
+ * @property string|null $folder_id
+ * @property-read \AdvisingApp\Research\Models\ResearchRequestFolder|null $folder
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \AdvisingApp\Research\Models\ResearchRequestQuestion> $questions
  * @property-read int|null $questions_count
  * @property-read \App\Models\User $user
@@ -4682,6 +4686,7 @@ class IdeHelperTrackedEventCount {}
  * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequest whereCreatedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequest whereDeletedAt($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequest whereFinishedAt($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequest whereFolderId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequest whereId($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequest whereResults($value)
  * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequest whereTitle($value)
@@ -4694,6 +4699,37 @@ class IdeHelperTrackedEventCount {}
 	class IdeHelperResearchRequest {}
 }
 
+namespace AdvisingApp\Research\Models{
+/**
+ * 
+ *
+ * @property string $id
+ * @property string $name
+ * @property string $user_id
+ * @property \Illuminate\Support\Carbon|null $created_at
+ * @property \Illuminate\Support\Carbon|null $updated_at
+ * @property \Illuminate\Support\Carbon|null $deleted_at
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \AdvisingApp\Research\Models\ResearchRequest> $requests
+ * @property-read int|null $requests_count
+ * @property-read \App\Models\User $user
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder newModelQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder newQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder onlyTrashed()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder query()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder whereCreatedAt($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder whereDeletedAt($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder whereId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder whereName($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder whereUpdatedAt($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder whereUserId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder withTrashed()
+ * @method static \Illuminate\Database\Eloquent\Builder<static>|ResearchRequestFolder withoutTrashed()
+ * @mixin \Eloquent
+ */
+	#[\AllowDynamicProperties]
+	class IdeHelperResearchRequestFolder {}
+}
+
 namespace AdvisingApp\Research\Models{
 /**
  * 
diff --git a/app-modules/ai/resources/views/components/assistant.blade.php b/app-modules/ai/resources/views/components/assistant.blade.php
index 4fe0c41a9d..888abb7739 100644
--- a/app-modules/ai/resources/views/components/assistant.blade.php
+++ b/app-modules/ai/resources/views/components/assistant.blade.php
@@ -112,11 +112,11 @@ class="flex flex-col gap-y-1 rounded-xl border border-gray-950/5 bg-white p-2 sh
                                     }
                                 }"
                                 :class="{
-                                    'px-2 group flex rounded-lg w-full items-center outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5 space-x-1': true,
-                                    'bg-gray-100 dark:bg-white/5': thread.id === $wire.thread?.id
+                                    'px-2 group flex rounded-lg w-full items-center outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5 gap-x-1': true,
+                                    'bg-gray-100 dark:bg-white/5': thread.id === $wire.selectedThreadId
                                 }"
                             >
-                                <div class="flex flex-1 items-center gap-3">
+                                <div class="flex min-w-0 flex-1 items-center gap-3">
                                     <template x-if="$wire.folders.length">
                                         <button
                                             type="button"
@@ -125,8 +125,10 @@ class="flex flex-col gap-y-1 rounded-xl border border-gray-950/5 bg-white p-2 sh
                                             x-on:dragend="end"
                                             :class="{
                                                 'flex items-center cursor-move': true,
-                                                'text-gray-700 dark:text-gray-200': thread.id !== $wire.thread?.id,
-                                                'text-primary-600 dark:text-primary-400': thread.id === $wire.thread?.id
+                                                'text-gray-700 dark:text-gray-200': thread.id !== $wire
+                                                    .selectedThreadId,
+                                                'text-primary-600 dark:text-primary-400': thread.id === $wire
+                                                    .selectedThreadId
                                             }"
                                         >
                                             <template x-if="loading.type !== 'thread' || loading.identifier !== thread.id">
@@ -140,7 +142,7 @@ class="flex flex-col gap-y-1 rounded-xl border border-gray-950/5 bg-white p-2 sh
                                     </template>
 
                                     <button
-                                        class="relative flex flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-left text-sm"
+                                        class="relative flex min-w-0 flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-left text-sm"
                                         type="button"
                                         x-on:click="selectThread(thread)"
                                     >
@@ -250,24 +252,26 @@ class="flex flex-col gap-y-1"
                                     class="group flex w-full cursor-move items-center rounded-lg px-2 outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5"
                                 >
                                     <x-filament::icon-button
+                                        class="flex-shrink-0"
                                         icon="heroicon-o-folder-open"
                                         x-show="expanded(folder.id)"
                                         x-on:click="expand(folder.id)"
                                     />
                                     <x-filament::icon-button
+                                        class="flex-shrink-0"
                                         icon="heroicon-o-folder"
                                         x-show="! expanded(folder.id)"
                                         x-on:click="expand(folder.id)"
                                     />
 
                                     <div
-                                        class="group flex w-full cursor-pointer items-center space-x-1 rounded-lg px-2 outline-none transition duration-75 focus:bg-gray-100 dark:focus:bg-white/5">
+                                        class="group flex w-full cursor-pointer items-center gap-x-1 rounded-lg px-2 outline-none transition duration-75 focus:bg-gray-100 dark:focus:bg-white/5">
                                         <div
-                                            class="relative flex flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-sm"
+                                            class="relative flex min-w-0 flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-sm"
                                             x-on:click="expand(folder.id)"
                                         >
                                             <div
-                                                class="flex-1 truncate"
+                                                class="min-w-0 flex-1 truncate"
                                                 x-bind:class="{
                                                     'text-primary-600 dark:text-primary-400': expanded(folder.id)
                                                 }"
@@ -275,7 +279,7 @@ class="flex-1 truncate"
                                             ></div>
                                         </div>
 
-                                        <div class="flex items-center gap-1">
+                                        <div class="flex flex-shrink-0 items-center gap-1">
                                             <template
                                                 x-if="loading.type !== 'renameFolderAction' || loading.identifier !== folder.id"
                                             >
@@ -344,11 +348,11 @@ class="relative hidden h-5 w-5 group-hover:inline-flex"
                                         }"
                                         x-show="expanded(folder.id)"
                                         :class="{
-                                            'px-2 group flex rounded-lg w-full items-center outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5 space-x-1': true,
-                                            'bg-gray-100 dark:bg-white/5': thread.id === $wire.thread?.id
+                                            'px-2 group flex rounded-lg w-full items-center outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5 gap-x-1': true,
+                                            'bg-gray-100 dark:bg-white/5': thread.id === $wire.selectedThreadId
                                         }"
                                     >
-                                        <div class="flex flex-1 items-center gap-3">
+                                        <div class="flex min-w-0 flex-1 items-center gap-3">
                                             <button
                                                 type="button"
                                                 draggable="true"
@@ -356,9 +360,10 @@ class="relative hidden h-5 w-5 group-hover:inline-flex"
                                                 x-on:dragend="end"
                                                 :class="{
                                                     'flex items-center cursor-move': true,
-                                                    'text-gray-700 dark:text-gray-200': thread.id !== $wire.thread?.id,
-                                                    'text-primary-600 dark:text-primary-400': thread.id === $wire.thread
-                                                        ?.id
+                                                    'text-gray-700 dark:text-gray-200': thread.id !== $wire
+                                                        .selectedThreadId,
+                                                    'text-primary-600 dark:text-primary-400': thread.id === $wire
+                                                        .selectedThreadId,
                                                 }"
                                             >
                                                 <template
@@ -375,7 +380,7 @@ class="relative hidden h-5 w-5 group-hover:inline-flex"
                                             </button>
 
                                             <button
-                                                class="relative flex flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-left text-sm"
+                                                class="relative flex min-w-0 flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-left text-sm"
                                                 type="button"
                                                 x-on:click="selectThread(thread)"
                                             >
@@ -386,7 +391,7 @@ class="relative flex flex-1 items-center justify-center gap-x-3 rounded-lg py-2
                                                         'text-gray-700 dark:text-gray-200': thread.id !== $wire
                                                             .selectedThreadId,
                                                         'text-primary-600 dark:text-primary-400': thread.id === $wire
-                                                            .selectedThreadId
+                                                            .selectedThreadId,
                                                     }"
                                                 >
                                                 </span>
diff --git a/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageFolders.php b/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageFolders.php
index 0e2584b1a6..31ac7693aa 100644
--- a/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageFolders.php
+++ b/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageFolders.php
@@ -101,6 +101,8 @@ public function newFolderAction(): Action
                 $folder->application = static::APPLICATION;
                 $folder->user()->associate(auth()->user());
                 $folder->save();
+
+                $this->folders = $this->getFolders();
             })
             ->icon('heroicon-m-folder-plus')
             ->color('primary')
diff --git a/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageThreads.php b/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageThreads.php
index d2399a6fd3..d3ca8c0f0b 100644
--- a/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageThreads.php
+++ b/app-modules/ai/src/Filament/Pages/Assistant/Concerns/CanManageThreads.php
@@ -73,6 +73,8 @@ trait CanManageThreads
     #[Locked]
     public ?AiThread $thread = null;
 
+    public ?string $selectedThreadId = null;
+
     public $assistantSwitcher = null;
 
     public $assistantSwitcherMobile = null;
@@ -193,6 +195,7 @@ public function createThread(?AiAssistant $assistant = null): void
         }
 
         $this->thread = app(CreateThread::class)(static::APPLICATION, $assistant);
+        $this->selectedThreadId = $this->thread->getKey();
     }
 
     public function getThreadsWithoutAFolder(): array
@@ -250,6 +253,7 @@ public function selectThread(?array $thread): void
         }
 
         $this->thread = $thread;
+        $this->selectedThreadId = $this->thread->getKey();
 
         $service = $this->thread->assistant->model->getService();
 
diff --git a/app-modules/ai/src/Filament/Pages/ResearchRequests.php b/app-modules/research/database/migrations/2025_05_14_161739_create_research_request_folders_table.php
similarity index 71%
rename from app-modules/ai/src/Filament/Pages/ResearchRequests.php
rename to app-modules/research/database/migrations/2025_05_14_161739_create_research_request_folders_table.php
index 8dd39a86a9..a5de914b94 100644
--- a/app-modules/ai/src/Filament/Pages/ResearchRequests.php
+++ b/app-modules/research/database/migrations/2025_05_14_161739_create_research_request_folders_table.php
@@ -34,29 +34,24 @@
 </COPYRIGHT>
 */
 
-namespace AdvisingApp\Ai\Filament\Pages;
+use Illuminate\Database\Migrations\Migration;
+use Tpetry\PostgresqlEnhanced\Schema\Blueprint;
+use Tpetry\PostgresqlEnhanced\Support\Facades\Schema;
 
-use AdvisingApp\Authorization\Enums\LicenseType;
-use App\Models\User;
-use Filament\Pages\Page;
-
-class ResearchRequests extends Page
-{
-    protected static ?string $navigationGroup = 'Artificial Intelligence';
-
-    protected static ?int $navigationSort = 30;
-
-    protected static string $view = 'filament.pages.coming-soon';
-
-    public static function canAccess(): bool
+return new class () extends Migration {
+    public function up(): void
     {
-        /** @var User $user */
-        $user = auth()->user();
-
-        if (! $user->hasLicense(LicenseType::ConversationalAi)) {
-            return false;
-        }
+        Schema::create('research_request_folders', function (Blueprint $table) {
+            $table->uuid('id')->primary();
+            $table->string('name');
+            $table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
+        });
+    }
 
-        return $user->can(['assistant.view-any', 'assistant.*.view']);
+    public function down(): void
+    {
+        Schema::dropIfExists('research_request_folders');
     }
-}
+};
diff --git a/app-modules/research/database/migrations/2025_05_14_161835_add_folder_id_column_to_research_requests_table.php b/app-modules/research/database/migrations/2025_05_14_161835_add_folder_id_column_to_research_requests_table.php
new file mode 100644
index 0000000000..538a52d1ae
--- /dev/null
+++ b/app-modules/research/database/migrations/2025_05_14_161835_add_folder_id_column_to_research_requests_table.php
@@ -0,0 +1,55 @@
+<?php
+
+/*
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+*/
+
+use Illuminate\Database\Migrations\Migration;
+use Tpetry\PostgresqlEnhanced\Schema\Blueprint;
+use Tpetry\PostgresqlEnhanced\Support\Facades\Schema;
+
+return new class () extends Migration {
+    public function up(): void
+    {
+        Schema::table('research_requests', function (Blueprint $table) {
+            $table->foreignUuid('folder_id')->nullable()->constrained('research_request_folders')->nullOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('research_requests', function (Blueprint $table) {
+            $table->dropConstrainedForeignId('folder_id');
+        });
+    }
+};
diff --git a/app-modules/research/resources/js/requests.js b/app-modules/research/resources/js/requests.js
new file mode 100644
index 0000000000..25520ab05c
--- /dev/null
+++ b/app-modules/research/resources/js/requests.js
@@ -0,0 +1,163 @@
+/*
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+*/
+document.addEventListener('alpine:init', () => {
+    Alpine.data('requests', ($wire) => ({
+        loading: {
+            type: null,
+            identifier: null,
+        },
+        requestId: null,
+        startFolder: null,
+        dragging: false,
+        expandedFolder: null,
+        async drop(folderId) {
+            try {
+                if (this.startFolder === folderId) {
+                    return;
+                }
+
+                const result = await this.$wire.movedRequest(this.requestId, folderId);
+
+                if (result.original.success) {
+                    if (folderId) {
+                        this.expandedFolder = folderId;
+                    }
+
+                    new FilamentNotification()
+                        .icon('heroicon-o-check-circle')
+                        .title(result.original.message)
+                        .iconColor('success')
+                        .send();
+                } else {
+                    new FilamentNotification()
+                        .icon('heroicon-o-x-circle')
+                        .title(result.original.message)
+                        .iconColor('danger')
+                        .send();
+                }
+            } catch (exception) {
+                new FilamentNotification()
+                    .icon('heroicon-o-x-circle')
+                    .title('Something went wrong, please try again later.')
+                    .iconColor('danger')
+                    .send();
+            } finally {
+                this.requestId = null;
+                this.startFolder = null;
+            }
+        },
+        start(requestId, folderId) {
+            this.dragging = true;
+            this.requestId = requestId;
+            this.startFolder = folderId;
+        },
+        end() {
+            this.dragging = false;
+        },
+        expand(folderId) {
+            if (this.expandedFolder === folderId) {
+                this.expandedFolder = null;
+            } else {
+                this.expandedFolder = folderId;
+            }
+        },
+        expanded(folderId) {
+            return this.expandedFolder === folderId;
+        },
+        async selectRequest(request) {
+            this.loading.type = 'request';
+            this.loading.identifier = request.id;
+
+            await $wire.selectRequest(request);
+
+            this.loading.type = null;
+            this.loading.identifier = null;
+
+            let url = new URL(window.location);
+            history.pushState({}, '', url.pathname);
+        },
+        async moveRequest(requestId) {
+            this.loading.type = 'moveRequestAction';
+            this.loading.identifier = requestId;
+
+            await $wire.mountAction('moveRequest', { request: requestId });
+
+            this.loading.type = null;
+            this.loading.identifier = null;
+        },
+        async editRequest(requestId) {
+            this.loading.type = 'editRequestAction';
+            this.loading.identifier = requestId;
+
+            await $wire.mountAction('editRequest', { request: requestId });
+
+            this.loading.type = null;
+            this.loading.identifier = null;
+        },
+        async deleteRequest(requestId) {
+            this.loading.type = 'deleteRequestAction';
+            this.loading.identifier = requestId;
+
+            await $wire.mountAction('deleteRequest', { request: requestId });
+
+            this.loading.type = null;
+            this.loading.identifier = null;
+        },
+        async renameFolder(folderId) {
+            this.loading.type = 'renameFolderAction';
+            this.loading.identifier = folderId;
+
+            await $wire.mountAction('renameFolder', { folder: folderId });
+
+            this.loading.type = null;
+            this.loading.identifier = null;
+        },
+        async deleteFolder(folderId) {
+            this.loading.type = 'deleteFolderAction';
+            this.loading.identifier = folderId;
+
+            await $wire.mountAction('deleteFolder', { folder: folderId });
+
+            this.loading.type = null;
+            this.loading.identifier = null;
+        },
+    }));
+});
+
+document.addEventListener('livewire:init', () => {
+    Livewire.on('remove-request-param', (event) => {
+        let url = new URL(window.location);
+        history.pushState({}, '', url.pathname);
+    });
+});
diff --git a/app-modules/research/resources/views/filament/pages/manage-research-requests.blade.php b/app-modules/research/resources/views/filament/pages/manage-research-requests.blade.php
new file mode 100644
index 0000000000..41780eed65
--- /dev/null
+++ b/app-modules/research/resources/views/filament/pages/manage-research-requests.blade.php
@@ -0,0 +1,522 @@
+{{--
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+--}}
+@php
+    use AdvisingApp\Research\Filament\Pages\NewResearchRequest;
+    use Filament\Support\Enums\ActionSize;
+@endphp
+
+<div class="h-[calc(100dvh-4rem)]">
+    @if ($this->isConsented)
+        @capture($sidebarContent)
+            <div class="flex select-none flex-col gap-y-2">
+                <div class="grid w-full grid-cols-2 gap-2">
+                    <x-filament::button
+                        icon="heroicon-m-plus"
+                        tag="a"
+                        :href="NewResearchRequest::getUrl()"
+                    >
+                        New Request
+                    </x-filament::button>
+
+                    {{ $this->newFolderAction }}
+                </div>
+
+                <template x-if="$wire.requestsWithoutAFolder.length">
+                    <ul
+                        class="flex flex-col gap-y-1 rounded-xl border border-gray-950/5 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900"
+                        id="folder-{{ null }}"
+                        x-on:drop.prevent="drop('{{ null }}')"
+                        x-on:dragenter.prevent
+                        x-on:dragover.prevent
+                    >
+                        <template
+                            x-for="request in $wire.requestsWithoutAFolder"
+                            :key="request.id"
+                        >
+                            <li
+                                :id="`request-${request.id}`"
+                                :class="{
+                                    'px-2 group flex rounded-lg w-full items-center outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5 space-x-1': true,
+                                    'bg-gray-100 dark:bg-white/5': request.id === $wire.selectedRequestId
+                                }"
+                            >
+                                <div class="flex min-w-0 flex-1 items-center gap-3">
+                                    <template x-if="$wire.folders.length">
+                                        <button
+                                            type="button"
+                                            draggable="true"
+                                            x-on:dragstart="start(request.id, null)"
+                                            x-on:dragend="end"
+                                            :class="{
+                                                'flex items-center cursor-move': true,
+                                                'text-gray-700 dark:text-gray-200': request.id !== $wire
+                                                    .selectedRequestId,
+                                                'text-primary-600 dark:text-primary-400': request.id === $wire
+                                                    .selectedRequestId
+                                            }"
+                                        >
+                                            <template
+                                                x-if="loading.type !== 'request' || loading.identifier !== request.id">
+                                                <x-heroicon-m-bars-2 class="h-5 w-5" />
+                                            </template>
+
+                                            <template
+                                                x-if="loading.type === 'request' && loading.identifier === request.id">
+                                                <x-filament::loading-indicator class="h-5 w-5" />
+                                            </template>
+                                        </button>
+                                    </template>
+
+                                    <button
+                                        class="relative flex min-w-0 flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-left text-sm"
+                                        type="button"
+                                        x-on:click="selectRequest(request)"
+                                    >
+                                        <span
+                                            x-text="request.title"
+                                            :class="{
+                                                'flex-1 truncate': true,
+                                                'text-gray-700 dark:text-gray-200': request.id !== $wire
+                                                    .selectedRequestId,
+                                                'text-primary-600 dark:text-primary-400': request.id === $wire
+                                                    .selectedRequestId
+                                            }"
+                                        >
+                                        </span>
+                                    </button>
+                                </div>
+
+                                <div class="flex items-center gap-1">
+                                    <template
+                                        x-if="loading.type !== 'moveRequestAction' || loading.identifier !== request.id"
+                                    >
+                                        <x-filament::icon-button
+                                            class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                            icon="heroicon-m-arrow-down-on-square"
+                                            x-on:click="moveRequest(request.id)"
+                                            label="Move request to a different folder"
+                                            color="warning"
+                                            size="{{ ActionSize::ExtraSmall }}"
+                                        />
+                                    </template>
+                                    <template
+                                        x-if="loading.type === 'moveRequestAction' && loading.identifier === request.id"
+                                    >
+                                        <x-filament::loading-indicator
+                                            class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                        />
+                                    </template>
+
+                                    <template
+                                        x-if="loading.type !== 'editRequestAction' || loading.identifier !== request.id"
+                                    >
+                                        <x-filament::icon-button
+                                            class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                            icon="heroicon-m-pencil"
+                                            x-on:click="editRequest(request.id)"
+                                            label="Edit name of the request"
+                                            color="warning"
+                                            size="{{ ActionSize::ExtraSmall }}"
+                                        />
+                                    </template>
+                                    <template
+                                        x-if="loading.type === 'editRequestAction' && loading.identifier === request.id"
+                                    >
+                                        <x-filament::loading-indicator
+                                            class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                        />
+                                    </template>
+
+                                    <template
+                                        x-if="loading.type !== 'deleteRequestAction' || loading.identifier !== request.id"
+                                    >
+                                        <x-filament::icon-button
+                                            class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                            icon="heroicon-m-trash"
+                                            x-on:click="deleteRequest(request.id)"
+                                            label="Delete the request"
+                                            color="danger"
+                                            size="{{ ActionSize::ExtraSmall }}"
+                                        />
+                                    </template>
+                                    <template
+                                        x-if="loading.type === 'deleteRequestAction' && loading.identifier === request.id"
+                                    >
+                                        <x-filament::loading-indicator
+                                            class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                        />
+                                    </template>
+                                </div>
+                            </li>
+                        </template>
+                    </ul>
+                </template>
+                <template x-if="!$wire.requestsWithoutAFolder.length">
+                    <div
+                        class="flex flex-col gap-y-1 rounded-xl border border-dashed border-gray-950/5 bg-white px-3 py-2 text-gray-500 shadow-sm dark:border-white/10 dark:bg-gray-900"
+                        x-show="dragging"
+                        x-on:drop.prevent="drop('{{ null }}')"
+                        x-on:dragenter.prevent
+                        x-on:dragover.prevent
+                    >
+                        <div class="text-sm">
+                            Drag requests here to move them out of a folder
+                        </div>
+                    </div>
+                </template>
+                <template x-if="$wire.folders.length">
+                    <div
+                        class="flex flex-col gap-y-3 rounded-xl border border-gray-950/5 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900">
+                        <template
+                            x-for="folder in $wire.folders"
+                            :key="folder.id"
+                        >
+                            <ul
+                                class="flex flex-col gap-y-1"
+                                :id="`folder-${folder.id}`"
+                                x-on:drop.prevent="drop(folder.id)"
+                                x-on:dragenter.prevent
+                                x-on:dragover.prevent
+                            >
+                                <span
+                                    class="group flex w-full cursor-move items-center rounded-lg px-2 outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5"
+                                >
+                                    <x-filament::icon-button
+                                        class="flex-shrink-0"
+                                        icon="heroicon-o-folder-open"
+                                        x-show="expanded(folder.id)"
+                                        x-on:click="expand(folder.id)"
+                                    />
+                                    <x-filament::icon-button
+                                        class="flex-shrink-0"
+                                        icon="heroicon-o-folder"
+                                        x-show="! expanded(folder.id)"
+                                        x-on:click="expand(folder.id)"
+                                    />
+
+                                    <div
+                                        class="group flex w-full cursor-pointer items-center gap-x-1 rounded-lg px-2 outline-none transition duration-75 focus:bg-gray-100 dark:focus:bg-white/5">
+                                        <div
+                                            class="relative flex min-w-0 flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-sm"
+                                            x-on:click="expand(folder.id)"
+                                        >
+                                            <div
+                                                class="min-w-0 flex-1 truncate"
+                                                x-bind:class="{
+                                                    'text-primary-600 dark:text-primary-400': expanded(folder.id)
+                                                }"
+                                                x-text="`${folder.name} ${folder.requests.length ? '(' + folder.requests.length + ')' : ''}`"
+                                            ></div>
+                                        </div>
+
+                                        <div class="flex flex-shrink-0 items-center gap-1">
+                                            <template
+                                                x-if="loading.type !== 'renameFolderAction' || loading.identifier !== folder.id"
+                                            >
+                                                <x-filament::icon-button
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                    icon="heroicon-m-pencil"
+                                                    x-on:click="renameFolder(folder.id)"
+                                                    label="Rename Folder"
+                                                    color="warning"
+                                                    size="{{ ActionSize::ExtraSmall }}"
+                                                />
+                                            </template>
+                                            <template
+                                                x-if="loading.type === 'renameFolderAction' && loading.identifier === folder.id"
+                                            >
+                                                <x-filament::loading-indicator
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                />
+                                            </template>
+
+                                            <template
+                                                x-if="loading.type !== 'deleteFolderAction' || loading.identifier !== folder.id"
+                                            >
+                                                <x-filament::icon-button
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                    icon="heroicon-m-trash"
+                                                    x-on:click="deleteFolder(folder.id)"
+                                                    label="Delete Folder"
+                                                    color="danger"
+                                                    size="{{ ActionSize::ExtraSmall }}"
+                                                />
+                                            </template>
+                                            <template
+                                                x-if="loading.type === 'deleteFolderAction' && loading.identifier === folder.id"
+                                            >
+                                                <x-filament::loading-indicator
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                />
+                                            </template>
+                                        </div>
+                                    </div>
+                                </span>
+                                <template
+                                    x-for="request in folder.requests"
+                                    :key="request.id"
+                                >
+                                    <li
+                                        :id="`request-${request.id}`"
+                                        x-show="expanded(folder.id)"
+                                        :class="{
+                                            'px-2 group flex rounded-lg w-full items-center outline-none transition duration-75 hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-white/5 dark:focus:bg-white/5 space-x-1': true,
+                                            'bg-gray-100 dark:bg-white/5': request.id === $wire.selectedRequestId
+                                        }"
+                                    >
+                                        <div class="flex min-w-0 flex-1 items-center gap-3">
+                                            <button
+                                                type="button"
+                                                draggable="true"
+                                                x-on:dragstart="start(request.id, folder.id)"
+                                                x-on:dragend="end"
+                                                :class="{
+                                                    'flex items-center cursor-move': true,
+                                                    'text-gray-700 dark:text-gray-200': request.id !== $wire
+                                                        .selectedRequestId,
+                                                    'text-primary-600 dark:text-primary-400': request.id === $wire
+                                                        .selectedRequestId
+                                                }"
+                                            >
+                                                <template
+                                                    x-if="loading.type !== 'request' || loading.identifier !== request.id"
+                                                >
+                                                    <x-heroicon-m-bars-2 class="h-5 w-5" />
+                                                </template>
+
+                                                <template
+                                                    x-if="loading.type === 'request' && loading.identifier === request.id"
+                                                >
+                                                    <x-filament::loading-indicator class="h-5 w-5" />
+                                                </template>
+                                            </button>
+
+                                            <button
+                                                class="relative flex min-w-0 flex-1 items-center justify-center gap-x-3 rounded-lg py-2 text-left text-sm"
+                                                type="button"
+                                                x-on:click="selectRequest(request)"
+                                            >
+                                                <span
+                                                    x-text="request.title"
+                                                    :class="{
+                                                        'flex-1 truncate': true,
+                                                        'text-gray-700 dark:text-gray-200': request.id !== $wire
+                                                            .selectedRequestId,
+                                                        'text-primary-600 dark:text-primary-400': request.id === $wire
+                                                            .selectedRequestId,
+                                                    }"
+                                                >
+                                                </span>
+                                            </button>
+                                        </div>
+
+                                        <div class="flex items-center gap-1">
+                                            <template
+                                                x-if="loading.type !== 'moveRequestAction' || loading.identifier !== request.id"
+                                            >
+                                                <x-filament::icon-button
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                    icon="heroicon-m-arrow-down-on-square"
+                                                    x-on:click="moveRequest(request.id)"
+                                                    label="Move request to a different folder"
+                                                    color="warning"
+                                                    size="{{ ActionSize::ExtraSmall }}"
+                                                />
+                                            </template>
+                                            <template
+                                                x-if="loading.type === 'moveRequestAction' && loading.identifier === request.id"
+                                            >
+                                                <x-filament::loading-indicator
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                />
+                                            </template>
+
+                                            <template
+                                                x-if="loading.type !== 'editRequestAction' || loading.identifier !== request.id"
+                                            >
+                                                <x-filament::icon-button
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                    icon="heroicon-m-pencil"
+                                                    x-on:click="editRequest(request.id)"
+                                                    label="Edit title of the request"
+                                                    color="warning"
+                                                    size="{{ ActionSize::ExtraSmall }}"
+                                                />
+                                            </template>
+                                            <template
+                                                x-if="loading.type === 'editRequestAction' && loading.identifier === request.id"
+                                            >
+                                                <x-filament::loading-indicator
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                />
+                                            </template>
+
+                                            <template
+                                                x-if="loading.type !== 'deleteRequestAction' || loading.identifier !== request.id"
+                                            >
+                                                <x-filament::icon-button
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                    icon="heroicon-m-trash"
+                                                    x-on:click="deleteRequest(request.id)"
+                                                    label="Delete the request"
+                                                    color="danger"
+                                                    size="{{ ActionSize::ExtraSmall }}"
+                                                />
+                                            </template>
+                                            <template
+                                                x-if="loading.type === 'deleteRequestAction' && loading.identifier === request.id"
+                                            >
+                                                <x-filament::loading-indicator
+                                                    class="relative hidden h-5 w-5 group-hover:inline-flex"
+                                                />
+                                            </template>
+                                        </div>
+                                    </li>
+                                </template>
+                            </ul>
+                        </template>
+                    </div>
+                </template>
+            </div>
+        @endcapture
+
+        <div
+            class="grid h-full flex-1 grid-cols-1 grid-rows-[1fr_auto] gap-2 lg:grid-cols-3 lg:gap-x-6 lg:gap-y-4 2xl:grid-cols-4"
+            x-data="requests($wire)"
+        >
+            <div class="col-span-1 hidden overflow-y-auto px-px pt-3 lg:block lg:pt-6">
+                {{ $sidebarContent() }}
+            </div>
+
+            <div class="col-span-1 flex flex-col gap-2 overflow-y-auto pt-3 lg:col-span-2 lg:pt-6 2xl:col-span-3">
+                <div class="lg:hidden">
+                    <x-filament::dropdown
+                        shift
+                        placement="bottom-start"
+                        width="lg"
+                        x-on:close-assistant-sidebar.window="close"
+                    >
+                        <x-slot name="trigger">
+                            <x-filament::icon-button
+                                label="Open menu"
+                                icon="heroicon-s-bars-3"
+                            />
+                        </x-slot>
+
+                        <div class="p-3">
+                            {{ $sidebarContent() }}
+                        </div>
+                    </x-filament::dropdown>
+                </div>
+
+                @if ($this->request)
+                    @include('research::results', ['researchRequest' => $this->request])
+                @endif
+            </div>
+
+            <div class="col-span-full hidden md:block">
+                <x-footer />
+            </div>
+
+            <div class="col-span-full mb-2 text-center text-xs md:hidden">
+                © 2016-{{ date('Y') }} <a
+                    class="text-blue-600 underline dark:text-blue-400"
+                    href="https://canyongbs.com/"
+                >Canyon GBS LLC</a>.
+            </div>
+        </div>
+    @elseif (!$this->isConsented)
+        <div x-init="$nextTick(() => $dispatch('open-modal', { id: 'consent-agreement' }))">
+            <x-filament::modal
+                id="consent-agreement"
+                width="5xl"
+                alignment="left"
+                :close-by-clicking-away="false"
+                :close-button="false"
+            >
+                <x-slot name="header">
+                    <h2 class="text-left text-xl font-semibold text-gray-950 dark:text-white">
+                        {{ $this->consentAgreement->title }}
+                    </h2>
+                </x-slot>
+
+                <div class="prose max-w-none text-left dark:prose-invert">
+                    {{ str($this->consentAgreement->description)->markdown()->sanitizeHtml()->toHtmlString() }}
+                </div>
+
+                <x-filament::section>
+                    <div class="prose max-w-none text-left text-[.7rem] leading-4 dark:prose-invert">
+                        {{ str($this->consentAgreement->body)->markdown()->sanitizeHtml()->toHtmlString() }}
+                    </div>
+                </x-filament::section>
+
+                <x-slot name="footer">
+                    <form
+                        class="flex w-full flex-col gap-6"
+                        wire:submit="confirmConsent"
+                    >
+                        <label>
+                            <x-filament::input.checkbox required />
+
+                            <span class="ml-2 text-sm font-medium">
+                                I agree to the terms and conditions
+                            </span>
+                        </label>
+
+                        <div class="flex justify-start gap-3">
+                            <x-filament::button
+                                wire:click="denyConsent"
+                                outlined
+                            >
+                                Cancel
+                            </x-filament::button>
+                            <x-filament::button type="submit">
+                                Continue
+                            </x-filament::button>
+                        </div>
+                    </form>
+                </x-slot>
+            </x-filament::modal>
+        </div>
+    @endif
+
+    <script src="{{ url('js/canyon-gbs/research/requests.js') . '?v=' . app('current-commit') }}"></script>
+    <style>
+        .footer {
+            display: none
+        }
+    </style>
+
+    <x-filament-actions::modals />
+</div>
diff --git a/app-modules/research/resources/views/results.blade.php b/app-modules/research/resources/views/results.blade.php
index 2821053a26..9d5d676920 100644
--- a/app-modules/research/resources/views/results.blade.php
+++ b/app-modules/research/resources/views/results.blade.php
@@ -35,7 +35,7 @@
     use League\CommonMark\Extension\Footnote\FootnoteExtension;
 @endphp
 
-<div @if ($this->hasResearchStarted && !$researchRequest?->finished_at) wire:poll.3s @endif>
+<div @if ($researchRequest?->hasStarted() && !$researchRequest?->finished_at) wire:poll.3s @endif>
     @if (!$researchRequest?->finished_at)
         <div class="flex items-center gap-2">
             <x-filament::loading-indicator class="h-5 w-5" /> Researching...
diff --git a/app-modules/research/src/Filament/Pages/ManageResearchRequests.php b/app-modules/research/src/Filament/Pages/ManageResearchRequests.php
new file mode 100644
index 0000000000..d8c35c8119
--- /dev/null
+++ b/app-modules/research/src/Filament/Pages/ManageResearchRequests.php
@@ -0,0 +1,88 @@
+<?php
+
+/*
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+*/
+
+namespace AdvisingApp\Research\Filament\Pages;
+
+use AdvisingApp\Ai\Settings\AiIntegrationsSettings;
+use AdvisingApp\Authorization\Enums\LicenseType;
+use AdvisingApp\Research\Filament\Pages\ManageResearchRequests\Concerns\CanManageConsent;
+use AdvisingApp\Research\Filament\Pages\ManageResearchRequests\Concerns\CanManageFolders;
+use AdvisingApp\Research\Filament\Pages\ManageResearchRequests\Concerns\CanManageRequests;
+use App\Features\ResearchRequests;
+use App\Models\User;
+use Filament\Navigation\NavigationItem;
+use Filament\Pages\Page;
+
+class ManageResearchRequests extends Page
+{
+    use CanManageConsent;
+    use CanManageFolders;
+    use CanManageRequests;
+
+    protected static string $view = 'research::filament.pages.manage-research-requests';
+
+    protected static ?string $title = 'Research Requests';
+
+    /**
+     * @return array<NavigationItem>
+     */
+    public static function getNavigationItems(): array
+    {
+        return [
+            NavigationItem::make('Research Requests')
+                ->group('Artificial Intelligence')
+                ->isActiveWhen(fn (): bool => request()->routeIs(static::getRouteName(), NewResearchRequest::getRouteName()))
+                ->sort(30)
+                ->url(static::getUrl()),
+        ];
+    }
+
+    public static function canAccess(): bool
+    {
+        /** @var User $user */
+        $user = auth()->user();
+
+        if (! $user->hasLicense(LicenseType::ConversationalAi)) {
+            return false;
+        }
+
+        if (blank(app(AiIntegrationsSettings::class)->jina_deepsearch_ai_api_key)) {
+            return false;
+        }
+
+        return ResearchRequests::active();
+    }
+}
diff --git a/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageConsent.php b/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageConsent.php
new file mode 100644
index 0000000000..7d6e596da9
--- /dev/null
+++ b/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageConsent.php
@@ -0,0 +1,83 @@
+<?php
+
+/*
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+*/
+
+namespace AdvisingApp\Research\Filament\Pages\ManageResearchRequests\Concerns;
+
+use AdvisingApp\Consent\Enums\ConsentAgreementType;
+use AdvisingApp\Consent\Models\ConsentAgreement;
+use App\Filament\Pages\Dashboard;
+use Livewire\Attributes\Computed;
+
+/**
+ * @property-read ConsentAgreement $consentAgreement
+ * @property-read bool $isConsented
+ */
+trait CanManageConsent
+{
+    #[Computed]
+    public function consentAgreement(): ConsentAgreement
+    {
+        return ConsentAgreement::query()
+            ->where('type', ConsentAgreementType::AzureOpenAI)
+            ->first();
+    }
+
+    #[Computed]
+    public function isConsented(): bool
+    {
+        return auth()->user()->hasConsentedTo($this->consentAgreement);
+    }
+
+    public function confirmConsent(): void
+    {
+        $user = auth()->user();
+
+        if ($this->isConsented) {
+            return;
+        }
+
+        $user->consentTo($this->consentAgreement);
+
+        unset($this->isConsented);
+
+        $this->dispatch('close-modal', id: 'consent-agreement');
+    }
+
+    public function denyConsent(): void
+    {
+        $this->redirect(Dashboard::getUrl());
+    }
+}
diff --git a/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageFolders.php b/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageFolders.php
new file mode 100644
index 0000000000..ee1b7c3000
--- /dev/null
+++ b/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageFolders.php
@@ -0,0 +1,269 @@
+<?php
+
+/*
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+*/
+
+namespace AdvisingApp\Research\Filament\Pages\ManageResearchRequests\Concerns;
+
+use AdvisingApp\Research\Models\ResearchRequest;
+use AdvisingApp\Research\Models\ResearchRequestFolder;
+use App\Models\User;
+use Exception;
+use Filament\Actions\Action;
+use Filament\Actions\StaticAction;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\TextInput;
+use Filament\Support\Enums\ActionSize;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Validation\Rules\Unique;
+use Livewire\Attributes\Locked;
+use Symfony\Component\HttpFoundation\Response;
+
+trait CanManageFolders
+{
+    /**
+     * @var array<mixed>
+     */
+    #[Locked]
+    public array $folders = [];
+
+    public function mountCanManageFolders(): void
+    {
+        $this->folders = $this->getFolders();
+    }
+
+    /**
+     * @return array<mixed>
+     */
+    public function getFolders(): array
+    {
+        /** @var User $user */
+        $user = auth()->user();
+
+        return $user
+            ->researchRequestFolders()
+            ->with([/** @phpstan-ignore argument.type */
+                'requests' => fn (HasMany $query) => $query
+                    ->latest('updated_at'),
+            ])
+            ->orderBy('name')
+            ->get()
+            ->toArray();
+    }
+
+    public function newFolderAction(): Action
+    {
+        return Action::make('newFolder')
+            ->label('New Folder')
+            ->modalSubmitActionLabel('Create')
+            ->modalWidth('md')
+            ->form([
+                TextInput::make('name')
+                    ->autocomplete(false)
+                    ->required()
+                    ->unique(ResearchRequestFolder::class, modifyRuleUsing: function (Unique $rule) {
+                        return $rule
+                            ->where('user_id', auth()->id());
+                    }),
+            ])
+            ->action(function (array $arguments, array $data) {
+                $folder = new ResearchRequestFolder();
+                $folder->name = $data['name'];
+                $folder->user()->associate(auth()->user());
+                $folder->save();
+
+                $this->folders = $this->getFolders();
+            })
+            ->icon('heroicon-m-folder-plus')
+            ->color('primary')
+            ->modalSubmitAction(fn (StaticAction $action) => $action->color('primary'));
+    }
+
+    public function renameFolderAction(): Action
+    {
+        return Action::make('renameFolder')
+            ->modalSubmitActionLabel('Rename')
+            ->modalWidth('md')
+            ->size(ActionSize::ExtraSmall)
+            ->fillForm(fn (array $arguments) => [
+                'name' => auth()->user()->researchRequestFolders()
+                    ->find($arguments['folder'])
+                    ?->name,
+            ])
+            ->form([
+                TextInput::make('name')
+                    ->label('Name')
+                    ->autocomplete(false)
+                    ->placeholder('Rename this folder')
+                    ->required()
+                    ->unique(ResearchRequestFolder::class, modifyRuleUsing: function (Unique $rule) {
+                        return $rule
+                            ->where('user_id', auth()->id());
+                    }),
+            ])
+            ->action(function (array $arguments, array $data) {
+                auth()->user()->researchRequestFolders()
+                    ->find($arguments['folder'])
+                    ?->update(['name' => $data['name']]);
+
+                $this->folders = $this->getFolders();
+            })
+            ->icon('heroicon-m-pencil')
+            ->color('warning')
+            ->modalSubmitAction(fn (StaticAction $action) => $action->color('primary'))
+            ->iconButton()
+            ->extraAttributes([
+                'class' => 'relative inline-flex w-5 h-5 hidden group-hover:inline-flex',
+            ]);
+    }
+
+    public function deleteFolderAction(): Action
+    {
+        return Action::make('deleteFolder')
+            ->size(ActionSize::ExtraSmall)
+            ->requiresConfirmation()
+            ->modalDescription('Are you sure you wish to delete this folder?')
+            ->action(function (array $arguments) {
+                auth()->user()->researchRequestFolders()
+                    ->find($arguments['folder'])
+                    ?->delete();
+
+                $this->folders = $this->getFolders();
+            })
+            ->icon('heroicon-m-trash')
+            ->color('danger')
+            ->iconButton()
+            ->extraAttributes([
+                'class' => 'relative inline-flex w-5 h-5 hidden group-hover:inline-flex',
+            ]);
+    }
+
+    public function moveRequestAction(): Action
+    {
+        return Action::make('moveRequest')
+            ->label('Move request to a different folder')
+            ->modalSubmitActionLabel('Move')
+            ->modalWidth('md')
+            ->size(ActionSize::ExtraSmall)
+            ->form([
+                $this->folderSelect(),
+            ])
+            ->action(function (array $arguments, array $data) {
+                $request = auth()->user()->researchRequests()
+                    ->find($arguments['request']);
+
+                if (! $request) {
+                    return;
+                }
+
+                $folder = auth()->user()->researchRequestFolders()
+                    ->find($data['folder']);
+
+                $this->moveRequest($request, $folder);
+            })
+            ->icon('heroicon-m-arrow-down-on-square')
+            ->color('warning')
+            ->modalSubmitAction(fn (StaticAction $action) => $action->color('primary'))
+            ->iconButton()
+            ->extraAttributes([
+                'class' => 'relative inline-flex w-5 h-5 hidden group-hover:inline-flex',
+            ]);
+    }
+
+    public function movedRequest(?string $requestId, ?string $folderId): ?JsonResponse
+    {
+        if (blank($requestId)) {
+            return null;
+        }
+
+        $request = auth()->user()->researchRequests()
+            ->find($requestId);
+
+        if (! $request) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Request could not be found.',
+            ], Response::HTTP_NOT_FOUND);
+        }
+
+        $folder = filled($folderId) ?
+            auth()->user()->researchRequestFolders()
+                ->find($folderId) :
+            null;
+
+        try {
+            $this->moveRequest($request, $folder);
+        } catch (Exception $exception) {
+            report($exception);
+
+            return response()->json([
+                'success' => false,
+                'message' => 'Request could not be moved. Something went wrong, if this continues please contact support.',
+            ], Response::HTTP_BAD_REQUEST);
+        }
+
+        return response()->json([
+            'success' => true,
+            'message' => 'Request moved successfully.',
+        ], Response::HTTP_OK);
+    }
+
+    protected function folderSelect(): Select
+    {
+        return Select::make('folder')
+            ->options(fn (): array => auth()->user()
+                ->researchRequestFolders()
+                ->orderBy('name')
+                ->pluck('name', 'id')
+                ->all())
+            ->placeholder('-');
+    }
+
+    protected function moveRequest(ResearchRequest $request, ?ResearchRequestFolder $folder): void
+    {
+        if ($folder) {
+            $request->folder()
+                ->associate($folder)
+                ->save();
+        } else {
+            $request->folder()
+                ->disassociate()
+                ->save();
+        }
+
+        $this->requestsWithoutAFolder = $this->getRequestsWithoutAFolder();
+        $this->folders = $this->getFolders();
+    }
+}
diff --git a/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageRequests.php b/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageRequests.php
new file mode 100644
index 0000000000..409e3b7376
--- /dev/null
+++ b/app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageRequests.php
@@ -0,0 +1,201 @@
+<?php
+
+/*
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+*/
+
+namespace AdvisingApp\Research\Filament\Pages\ManageResearchRequests\Concerns;
+
+use AdvisingApp\Research\Models\ResearchRequest;
+use App\Models\User;
+use Filament\Actions\Action;
+use Filament\Actions\StaticAction;
+use Filament\Forms\Components\TextInput;
+use Filament\Support\Enums\ActionSize;
+use Illuminate\Support\Str;
+use Livewire\Attributes\Locked;
+
+trait CanManageRequests
+{
+    #[Locked]
+    public ?ResearchRequest $request = null;
+
+    public ?string $selectedRequestId = null;
+
+    /**
+     * @var array<mixed>
+     */
+    #[Locked]
+    public array $requestsWithoutAFolder = [];
+
+    public function mount(): void
+    {
+        if (request()->researchRequest) {
+            if (! Str::isUuid(request()->researchRequest)) {
+                $this->dispatch('remove-request-param');
+            } else {
+                $researchRequest = ResearchRequest::where('id', request()->researchRequest)->where('user_id', auth()->id())->first()?->toArray();
+
+                if ($researchRequest) {
+                    $this->selectRequest($researchRequest);
+                } else {
+                    $this->dispatch('remove-request-param');
+                }
+            }
+        }
+    }
+
+    public function mountCanManageRequests(): void
+    {
+        $this->requestsWithoutAFolder = $this->getRequestsWithoutAFolder();
+
+        if (! $this->request) {
+            $this->loadFirstRequest();
+        }
+    }
+
+    /**
+     * @return array<mixed>
+     */
+    public function getRequestsWithoutAFolder(): array
+    {
+        /** @var User $user */
+        $user = auth()->user();
+
+        return $user
+            ->researchRequests()
+            ->whereNotNull('title')
+            ->doesntHave('folder')
+            ->latest('updated_at')
+            ->get()
+            ->toArray();
+    }
+
+    public function loadFirstRequest(): void
+    {
+        $this->selectRequest(collect($this->requestsWithoutAFolder)->first());
+    }
+
+    /**
+     * @param array<mixed> $request
+     */
+    public function selectRequest(?array $request): void
+    {
+        if (! $request) {
+            return;
+        }
+
+        $request = ResearchRequest::find($request['id']);
+
+        if (
+            $this->request &&
+            blank($this->request->title)
+        ) {
+            $this->request->delete();
+        }
+
+        if (! $request->user()->is(auth()->user())) {
+            abort(404);
+        }
+
+        $this->request = $request;
+        $this->selectedRequestId = $this->request->getKey();
+    }
+
+    public function deleteRequestAction(): Action
+    {
+        return Action::make('deleteRequest')
+            ->size(ActionSize::ExtraSmall)
+            ->requiresConfirmation()
+            ->action(function (array $arguments) {
+                $request = auth()->user()->researchRequests()
+                    ->find($arguments['request']);
+
+                if (! $request) {
+                    return;
+                }
+
+                $request->delete();
+
+                $this->requestsWithoutAFolder = $this->getRequestsWithoutAFolder();
+                $this->folders = $this->getFolders();
+            })
+            ->icon('heroicon-m-trash')
+            ->color('danger')
+            ->iconButton()
+            ->extraAttributes([
+                'class' => 'relative inline-flex w-5 h-5 hidden group-hover:inline-flex',
+            ]);
+    }
+
+    public function editRequestAction(): Action
+    {
+        return Action::make('editRequest')
+            ->modalSubmitActionLabel('Save')
+            ->modalWidth('md')
+            ->size(ActionSize::ExtraSmall)
+            ->fillForm(fn (array $arguments) => [
+                'title' => auth()->user()->researchRequests()
+                    ->find($arguments['request'])
+                    ?->title,
+            ])
+            ->form([
+                TextInput::make('title')
+                    ->label('Title')
+                    ->autocomplete(false)
+                    ->placeholder('Rename this request')
+                    ->required(),
+            ])
+            ->action(function (array $arguments, array $data) {
+                $request = auth()->user()->researchRequests()
+                    ->find($arguments['request']);
+
+                if (! $request) {
+                    return;
+                }
+
+                $request->title = $data['title'];
+                $request->save();
+
+                $this->requestsWithoutAFolder = $this->getRequestsWithoutAFolder();
+                $this->folders = $this->getFolders();
+            })
+            ->icon('heroicon-m-pencil')
+            ->color('warning')
+            ->modalSubmitAction(fn (StaticAction $action) => $action->color('primary'))
+            ->iconButton()
+            ->extraAttributes([
+                'class' => 'relative inline-flex w-5 h-5 hidden group-hover:inline-flex',
+            ]);
+    }
+}
diff --git a/app-modules/research/src/Filament/Pages/NewResearchRequest.php b/app-modules/research/src/Filament/Pages/NewResearchRequest.php
index 952a90fd28..ca39b9f933 100644
--- a/app-modules/research/src/Filament/Pages/NewResearchRequest.php
+++ b/app-modules/research/src/Filament/Pages/NewResearchRequest.php
@@ -37,6 +37,7 @@
 namespace AdvisingApp\Research\Filament\Pages;
 
 use AdvisingApp\Ai\Settings\AiIntegrationsSettings;
+use AdvisingApp\Authorization\Enums\LicenseType;
 use AdvisingApp\Research\Actions\GenerateResearchQuestion;
 use AdvisingApp\Research\Jobs\Research;
 use AdvisingApp\Research\Models\ResearchRequest;
@@ -54,7 +55,6 @@
 
 /**
  * @property-read Form $form
- * @property-read bool $hasResearchStarted
  * @property-read ?ResearchRequest $researchRequest
  */
 class NewResearchRequest extends Page
@@ -73,6 +73,13 @@ class NewResearchRequest extends Page
 
     public static function canAccess(): bool
     {
+        /** @var User $user */
+        $user = auth()->user();
+
+        if (! $user->hasLicense(LicenseType::ConversationalAi)) {
+            return false;
+        }
+
         if (blank(app(AiIntegrationsSettings::class)->jina_deepsearch_ai_api_key)) {
             return false;
         }
@@ -95,12 +102,23 @@ public function mount(): void
         }
     }
 
+    /**
+     * @return array<string>
+     */
+    public function getBreadcrumbs(): array
+    {
+        return [
+            ManageResearchRequests::getUrl() => 'Research Requests',
+            'New',
+        ];
+    }
+
     public function form(Form $form): Form
     {
         return $form
             ->schema([
                 Wizard::make()
-                    ->startOnStep($this->hasResearchStarted ? 6 : ($this->researchRequest?->questions?->count() ?? 0) + 1)
+                    ->startOnStep($this->researchRequest?->hasStarted() ? 6 : ($this->researchRequest?->questions?->count() ?? 0) + 1)
                     ->steps([
                         Step::make('Topic')
                             ->schema([
@@ -140,7 +158,7 @@ public function form(Form $form): Form
                                 ]);
                                 $this->researchRequest->load('questions');
                             })
-                            ->disabled(fn (): bool => $this->hasResearchStarted),
+                            ->disabled(fn (): bool => $this->researchRequest?->hasStarted() ?? false),
                         Step::make('Question 1')
                             ->schema([
                                 Textarea::make('question_1')
@@ -161,7 +179,7 @@ public function form(Form $form): Form
                                     $this->researchRequest->load('questions');
                                 }
                             })
-                            ->disabled(fn (): bool => $this->hasResearchStarted),
+                            ->disabled(fn (): bool => $this->researchRequest?->hasStarted() ?? false),
                         Step::make('Question 2')
                             ->schema([
                                 Textarea::make('question_2')
@@ -182,7 +200,7 @@ public function form(Form $form): Form
                                     $this->researchRequest->load('questions');
                                 }
                             })
-                            ->disabled(fn (): bool => $this->hasResearchStarted),
+                            ->disabled(fn (): bool => $this->researchRequest?->hasStarted() ?? false),
                         Step::make('Question 3')
                             ->schema([
                                 Textarea::make('question_3')
@@ -203,7 +221,7 @@ public function form(Form $form): Form
                                     $this->researchRequest->load('questions');
                                 }
                             })
-                            ->disabled(fn (): bool => $this->hasResearchStarted),
+                            ->disabled(fn (): bool => $this->researchRequest?->hasStarted() ?? false),
                         Step::make('Question 4')
                             ->schema([
                                 Textarea::make('question_4')
@@ -213,7 +231,7 @@ public function form(Form $form): Form
                                     ->maxLength(2000),
                             ])
                             ->afterValidation(function (array $state) {
-                                if ($this->hasResearchStarted) {
+                                if ($this->researchRequest->hasStarted()) {
                                     return;
                                 }
 
@@ -224,26 +242,23 @@ public function form(Form $form): Form
                                 dispatch(app(Research::class, [
                                     'researchRequest' => $this->researchRequest,
                                 ]));
-
-                                unset($this->hasResearchStarted);
                             })
-                            ->disabled(fn (): bool => $this->hasResearchStarted),
+                            ->disabled(fn (): bool => $this->researchRequest?->hasStarted() ?? false),
                         Step::make('Results')
                             ->schema([
                                 View::make('research::results')
                                     ->viewData(['researchRequest' => $this->researchRequest]),
                             ]),
-                    ]),
+                    ])
+                    ->submitAction(filled($this->researchRequest?->title) ? Action::make('view')
+                        ->label('Continue')
+                        ->icon('heroicon-m-arrow-right')
+                        ->iconPosition('after')
+                        ->url(ManageResearchRequests::getUrl(['researchRequest' => $this->researchRequestId])) : null),
             ])
             ->statePath('data');
     }
 
-    #[Computed]
-    public function hasResearchStarted(): bool
-    {
-        return $this->researchRequest?->finished_at || filled($this->researchRequest?->results) || filled($this->researchRequest?->questions->get(3)?->response);
-    }
-
     #[Computed]
     public function researchRequest(): ?ResearchRequest
     {
@@ -264,7 +279,7 @@ public function getHeaderActions(): array
     {
         return [
             Action::make('restart')
-                ->label('New request')
+                ->label('New Request')
                 ->requiresConfirmation()
                 ->modalHeading('Restart this research request from scratch')
                 ->color('gray')
diff --git a/app-modules/research/src/Models/ResearchRequest.php b/app-modules/research/src/Models/ResearchRequest.php
index c4565371de..f950b8bcee 100644
--- a/app-modules/research/src/Models/ResearchRequest.php
+++ b/app-modules/research/src/Models/ResearchRequest.php
@@ -71,6 +71,14 @@ public function questions(): HasMany
         return $this->hasMany(ResearchRequestQuestion::class);
     }
 
+    /**
+     * @return BelongsTo<ResearchRequestFolder, $this>
+     */
+    public function folder(): BelongsTo
+    {
+        return $this->belongsTo(ResearchRequestFolder::class, 'folder_id');
+    }
+
     /**
      * @return BelongsTo<User, $this>
      */
@@ -78,4 +86,17 @@ public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);
     }
+
+    public function hasStarted(): bool
+    {
+        if ($this->finished_at) {
+            return true;
+        }
+
+        if (filled($this->results)) {
+            return true;
+        }
+
+        return filled($this->questions->get(3)?->response);
+    }
 }
diff --git a/app-modules/research/src/Models/ResearchRequestFolder.php b/app-modules/research/src/Models/ResearchRequestFolder.php
new file mode 100644
index 0000000000..cfc9cda65d
--- /dev/null
+++ b/app-modules/research/src/Models/ResearchRequestFolder.php
@@ -0,0 +1,74 @@
+<?php
+
+/*
+<COPYRIGHT>
+
+    Copyright © 2016-2025, Canyon GBS LLC. All rights reserved.
+
+    Advising App™ is licensed under the Elastic License 2.0. For more details,
+    see https://github.com/canyongbs/advisingapp/blob/main/LICENSE.
+
+    Notice:
+
+    - You may not provide the software to third parties as a hosted or managed
+      service, where the service provides users with access to any substantial set of
+      the features or functionality of the software.
+    - You may not move, change, disable, or circumvent the license key functionality
+      in the software, and you may not remove or obscure any functionality in the
+      software that is protected by the license key.
+    - You may not alter, remove, or obscure any licensing, copyright, or other notices
+      of the licensor in the software. Any use of the licensor’s trademarks is subject
+      to applicable law.
+    - Canyon GBS LLC respects the intellectual property rights of others and expects the
+      same in return. Canyon GBS™ and Advising App™ are registered trademarks of
+      Canyon GBS LLC, and we are committed to enforcing and protecting our trademarks
+      vigorously.
+    - The software solution, including services, infrastructure, and code, is offered as a
+      Software as a Service (SaaS) by Canyon GBS LLC.
+    - Use of this software implies agreement to the license terms and conditions as stated
+      in the Elastic License 2.0.
+
+    For more information or inquiries please visit our website at
+    https://www.canyongbs.com or contact us via email at legal@canyongbs.com.
+
+</COPYRIGHT>
+*/
+
+namespace AdvisingApp\Research\Models;
+
+use AdvisingApp\Ai\Models\Concerns\CanAddAssistantLicenseGlobalScope;
+use App\Models\BaseModel;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * @mixin IdeHelperResearchRequestFolder
+ */
+class ResearchRequestFolder extends BaseModel
+{
+    use CanAddAssistantLicenseGlobalScope;
+    use SoftDeletes;
+
+    protected $fillable = [
+        'name',
+        'user_id',
+    ];
+
+    /**
+     * @return HasMany<ResearchRequest, $this>
+     */
+    public function requests(): HasMany
+    {
+        return $this->hasMany(ResearchRequest::class, 'folder_id');
+    }
+
+    /**
+     * @return BelongsTo<User, $this>
+     */
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+}
diff --git a/app-modules/research/src/Providers/ResearchServiceProvider.php b/app-modules/research/src/Providers/ResearchServiceProvider.php
index 0aa42ba678..b9aaaa2cb9 100644
--- a/app-modules/research/src/Providers/ResearchServiceProvider.php
+++ b/app-modules/research/src/Providers/ResearchServiceProvider.php
@@ -37,6 +37,7 @@
 namespace AdvisingApp\Research\Providers;
 
 use AdvisingApp\Research\Models\ResearchRequest;
+use AdvisingApp\Research\Models\ResearchRequestFolder;
 use AdvisingApp\Research\Models\ResearchRequestQuestion;
 use AdvisingApp\Research\ResearchPlugin;
 use Filament\Panel;
@@ -54,6 +55,7 @@ public function boot(): void
     {
         Relation::morphMap([
             'research_request' => ResearchRequest::class,
+            'research_request_folder' => ResearchRequestFolder::class,
             'research_request_question' => ResearchRequestQuestion::class,
         ]);
     }
diff --git a/app/Models/User.php b/app/Models/User.php
index dba1328da1..3ffdb14698 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -63,6 +63,7 @@
 use AdvisingApp\Report\Models\TrackedEvent;
 use AdvisingApp\Report\Models\TrackedEventCount;
 use AdvisingApp\Research\Models\ResearchRequest;
+use AdvisingApp\Research\Models\ResearchRequestFolder;
 use AdvisingApp\Segment\Models\Segment;
 use AdvisingApp\StudentDataModel\Models\Student;
 use AdvisingApp\Task\Models\Task;
@@ -503,6 +504,14 @@ public function researchRequests(): HasMany
         return $this->hasMany(ResearchRequest::class);
     }
 
+    /**
+     * @return HasMany<ResearchRequestFolder, $this>
+     */
+    public function researchRequestFolders(): HasMany
+    {
+        return $this->hasMany(ResearchRequestFolder::class);
+    }
+
     public function canAccessPanel(Panel $panel): bool
     {
         return true;
diff --git a/bin/build.js b/bin/build.js
index c1f0359d54..aa341a3045 100644
--- a/bin/build.js
+++ b/bin/build.js
@@ -96,6 +96,12 @@ compile({
     outfile: './public/js/canyon-gbs/ai/chats.js',
 })
 
+compile({
+    ...defaultOptions,
+    entryPoints: ['./app-modules/research/resources/js/requests.js'],
+    outfile: './public/js/canyon-gbs/research/requests.js',
+})
+
 compile({
     ...defaultOptions,
     entryPoints: ['./app-modules/in-app-communication/resources/js/userToUserChat.js'],


Reviews:
user: login: Orrison
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T16:54:17Z
user: login: danharrin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:22:57Z
user: login: danharrin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:26:39Z
user: login: Orrison
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-20T18:08:20Z

Review comments:
diff_hunk: @@ -34,29 +34,24 @@
 </COPYRIGHT>
 */
 
-namespace AdvisingApp\Ai\Filament\Pages;
+use Illuminate\Database\Migrations\Migration;
+use Tpetry\PostgresqlEnhanced\Schema\Blueprint;
+use Tpetry\PostgresqlEnhanced\Support\Facades\Schema;
 
-use AdvisingApp\Authorization\Enums\LicenseType;
-use App\Models\User;
-use Filament\Pages\Page;
-
-class ResearchRequests extends Page
-{
-    protected static ?string $navigationGroup = 'Artificial Intelligence';
-
-    protected static ?int $navigationSort = 30;
-
-    protected static string $view = 'filament.pages.coming-soon';
-
-    public static function canAccess(): bool
+return new class () extends Migration {
+    public function up(): void
     {
-        /** @var User $user */
-        $user = auth()->user();
-
-        if (! $user->hasLicense(LicenseType::ConversationalAi)) {
-            return false;
-        }
+        Schema::create('research_request_folders', function (Blueprint $table) {
+            $table->uuid('id')->primary();
+            $table->string('name');
+            $table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
path: app-modules/research/database/migrations/2025_05_14_161739_create_research_request_folders_table.php
user: login: Orrison
body: We might want to consider timestamptz columns instead. https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_timestamp_.28without_time_zone.29
created_at: 2025-05-19T16:48:16Z
author_association: COLLABORATOR
path: app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageConsent.php
user: login: Orrison
body: Should consent be added now? Did Product ask for this in Research Assistant?
created_at: 2025-05-19T16:51:41Z
author_association: COLLABORATOR
path: app-modules/research/src/Filament/Pages/ManageResearchRequests/Concerns/CanManageConsent.php
user: login: danharrin
body: They did not ask, but I assumed they would want users to consent before sending data to Jina or OpenAI
created_at: 2025-05-19T17:22:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,29 +34,24 @@
 </COPYRIGHT>
 */
 
-namespace AdvisingApp\Ai\Filament\Pages;
+use Illuminate\Database\Migrations\Migration;
+use Tpetry\PostgresqlEnhanced\Schema\Blueprint;
+use Tpetry\PostgresqlEnhanced\Support\Facades\Schema;
 
-use AdvisingApp\Authorization\Enums\LicenseType;
-use App\Models\User;
-use Filament\Pages\Page;
-
-class ResearchRequests extends Page
-{
-    protected static ?string $navigationGroup = 'Artificial Intelligence';
-
-    protected static ?int $navigationSort = 30;
-
-    protected static string $view = 'filament.pages.coming-soon';
-
-    public static function canAccess(): bool
+return new class () extends Migration {
+    public function up(): void
     {
-        /** @var User $user */
-        $user = auth()->user();
-
-        if (! $user->hasLicense(LicenseType::ConversationalAi)) {
-            return false;
-        }
+        Schema::create('research_request_folders', function (Blueprint $table) {
+            $table->uuid('id')->primary();
+            $table->string('name');
+            $table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
+            $table->timestamps();
+            $table->softDeletes();
path: app-modules/research/database/migrations/2025_05_14_161739_create_research_request_folders_table.php
user: login: danharrin
body: These are the standard Laravel timestamp `created_at` and `updated_at`. As far as I know this is not what we use elsewhere?
created_at: 2025-05-19T17:26:39Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-19T17:56:08Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=canyongbs_advisingapp&pullRequest=1548) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [3 New issues](https://sonarcloud.io/project/issues?id=canyongbs_advisingapp&pullRequest=1548&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=canyongbs_advisingapp&pullRequest=1548&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=canyongbs_advisingapp&pullRequest=1548&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=canyongbs_advisingapp&pullRequest=1548&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [7.5% Duplication on New Code](https://sonarcloud.io/component_measures?id=canyongbs_advisingapp&pullRequest=1548&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=canyongbs_advisingapp&pullRequest=1548)


author_association: NONE