From f97c142c4cc2c10c15cc39ab0430eb82b9eb551e Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 27 Mar 2026 09:39:18 -0400 Subject: [PATCH 1/2] feat: add connection-scoped workspaces --- app/Http/Controllers/HomeController.php | 66 ++++--- app/Http/Controllers/WorkspaceController.php | 55 ++++++ app/Http/Requests/StoreWorkspaceRequest.php | 34 ++++ app/Models/InstanceConnection.php | 18 ++ app/Models/SurrealWorkspace.php | 27 +++ app/Models/Workspace.php | 29 ++- .../Connections/InstanceConnectionManager.php | 98 +++++++++++ database/factories/WorkspaceFactory.php | 31 ++++ ...6_03_27_063640_create_workspaces_table.php | 45 +++++ ...space_id_to_instance_connections_table.php | 44 +++++ .../components/desktop/nav-item.blade.php | 21 +++ resources/views/welcome.blade.php | 76 ++++++-- routes/web.php | 3 + tests/Feature/DesktopShellTest.php | 52 ++++-- tests/Feature/DesktopUiFeatureFlagTest.php | 5 +- .../InstanceConnectionManagementTest.php | 12 +- tests/Feature/SurrealWorkspaceModelTest.php | 24 +-- tests/Feature/WorkspaceManagementTest.php | 166 ++++++++++++++++++ 18 files changed, 733 insertions(+), 73 deletions(-) create mode 100644 app/Http/Controllers/WorkspaceController.php create mode 100644 app/Http/Requests/StoreWorkspaceRequest.php create mode 100644 app/Models/SurrealWorkspace.php create mode 100644 database/factories/WorkspaceFactory.php create mode 100644 database/migrations/2026_03_27_063640_create_workspaces_table.php create mode 100644 database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php create mode 100644 tests/Feature/WorkspaceManagementTest.php diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 32b42c2..5a5f1ab 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -4,6 +4,7 @@ use App\Features\Desktop\MvpShell; use App\Models\InstanceConnection; +use App\Models\SurrealWorkspace; use App\Models\User; use App\Models\Workspace; use App\Services\Surreal\SurrealRuntimeManager; @@ -18,6 +19,8 @@ class HomeController extends Controller { + private const FAVORITES_ENABLED = false; + /** * @return array{ * slug: string, @@ -35,19 +38,20 @@ class HomeController extends Controller * messages: array * } */ - private function activeWorkspaceState(InstanceConnection $activeConnection, bool $localReady): array + private function activeWorkspaceState(InstanceConnection $activeConnection, Workspace $workspace, bool $localReady): array { if ($activeConnection->kind === InstanceConnection::KIND_SERVER) { return [ - 'slug' => 'remote-instance', - 'label' => $activeConnection->name, - 'meta' => $this->connectionMeta($activeConnection), - 'prefix' => $this->connectionPrefix($activeConnection), - 'summary' => sprintf( - 'A connected server workspace for shared orchestration, worker presence, and linked team context on %s.', + 'slug' => $workspace->slug, + 'label' => $workspace->name, + 'meta' => $activeConnection->name, + 'prefix' => strtoupper(substr($workspace->name, 0, 1)), + 'summary' => $workspace->summary ?: sprintf( + '%s is the active workspace on %s for shared orchestration, worker presence, and linked team context.', + $workspace->name, $activeConnection->name, ), - 'room' => '# relay-ops', + 'room' => '# general', 'roomStatus' => 'remote', 'participants' => [ ['label' => 'You', 'meta' => 'Human'], @@ -106,14 +110,14 @@ private function activeWorkspaceState(InstanceConnection $activeConnection, bool } return [ - 'slug' => 'current-instance', - 'label' => $activeConnection->name, - 'meta' => $this->connectionMeta($activeConnection), - 'prefix' => $this->connectionPrefix($activeConnection), - 'summary' => $localReady - ? 'The embedded Surreal runtime is available and the primary workspace is ready.' - : 'A primary workspace on this instance for conversations, tasks, artifacts, and decisions.', - 'room' => '# design-room', + 'slug' => $workspace->slug, + 'label' => $workspace->name, + 'meta' => $activeConnection->name, + 'prefix' => strtoupper(substr($workspace->name, 0, 1)), + 'summary' => $workspace->summary ?: ($localReady + ? sprintf('The embedded Surreal runtime is available and %s is ready.', $workspace->name) + : sprintf('%s is a workspace on this instance for conversations, tasks, artifacts, and decisions.', $workspace->name)), + 'room' => '# general', 'roomStatus' => $localReady ? 'ready' : 'draft', 'participants' => [ ['label' => 'You', 'meta' => 'Human'], @@ -231,9 +235,27 @@ private function connectionLinks(EloquentCollection $connections, InstanceConnec ->all(); } + /** + * @param EloquentCollection $workspaces + * @return array + */ + private function workspaceLinks(EloquentCollection $workspaces, Workspace $activeWorkspace): array + { + return $workspaces + ->map(fn (Workspace $workspace): array => [ + 'id' => (int) $workspace->getKey(), + 'label' => $workspace->name, + 'prefix' => strtoupper(substr($workspace->name, 0, 1)), + 'active' => (int) $workspace->getKey() === (int) $activeWorkspace->getKey(), + 'tone' => 'room', + ]) + ->values() + ->all(); + } + /** * @param array{ - * room: string, + * label: string, * participants: array * } $workspace * @return array @@ -241,7 +263,7 @@ private function connectionLinks(EloquentCollection $connections, InstanceConnec private function favoriteLinks(array $workspace, string $viewerName): array { $favorites = [ - ['label' => $workspace['room'], 'active' => true, 'prefix' => '#', 'tone' => 'room'], + ['label' => $workspace['label'], 'active' => true, 'prefix' => $workspace['prefix'], 'tone' => 'room'], ['label' => $viewerName, 'prefix' => substr($viewerName, 0, 1), 'tone' => 'human'], ]; @@ -465,7 +487,7 @@ public function __invoke( try { if ($runtimeManager->ensureReady()) { - Workspace::desktopPreview(); + SurrealWorkspace::desktopPreview(); $localReady = true; } } catch (Throwable $exception) { @@ -488,12 +510,16 @@ public function __invoke( } $connections = $connectionManager->connectionsFor($request->user()); - $activeWorkspace = $this->activeWorkspaceState($activeConnection, $localReady); + $activeWorkspaceModel = $connectionManager->activeWorkspaceFor($activeConnection); + $workspaces = $connectionManager->workspacesFor($activeConnection); + $activeWorkspace = $this->activeWorkspaceState($activeConnection, $activeWorkspaceModel, $localReady); return view('welcome', [ 'mvpShellEnabled' => $mvpShellEnabled, 'activeConnection' => $activeConnection, 'connectionLinks' => $this->connectionLinks($connections, $activeConnection), + 'favoritesEnabled' => self::FAVORITES_ENABLED, + 'workspaceLinks' => $this->workspaceLinks($workspaces, $activeWorkspaceModel), 'activeWorkspace' => $activeWorkspace, 'favoriteLinks' => $this->favoriteLinks($activeWorkspace, $viewerName), 'roomLinks' => $this->roomLinks($activeConnection, $activeWorkspace['room']), diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php new file mode 100644 index 0000000..96c22b0 --- /dev/null +++ b/app/Http/Controllers/WorkspaceController.php @@ -0,0 +1,55 @@ +activeConnectionFor( + $request->user(), + $request->root(), + $request->session(), + ); + + if ($activeConnection->kind === InstanceConnection::KIND_SERVER && ! $activeConnection->is_authenticated) { + return to_route('connections.connect', $activeConnection); + } + + $connectionManager->createWorkspace($activeConnection, [ + 'name' => $request->validated('workspace_name'), + ]); + + return to_route('home'); + } + + public function activate( + Request $request, + Workspace $workspace, + InstanceConnectionManager $connectionManager, + ): RedirectResponse { + if ((int) $workspace->instanceConnection->user_id !== (int) $request->user()->getKey()) { + abort(404); + } + + $connectionManager->activateWorkspace($workspace, $request->session()); + + if ( + $workspace->instanceConnection->kind === InstanceConnection::KIND_SERVER + && ! $workspace->instanceConnection->is_authenticated + ) { + return to_route('connections.connect', $workspace->instanceConnection); + } + + return to_route('home'); + } +} diff --git a/app/Http/Requests/StoreWorkspaceRequest.php b/app/Http/Requests/StoreWorkspaceRequest.php new file mode 100644 index 0000000..39f9110 --- /dev/null +++ b/app/Http/Requests/StoreWorkspaceRequest.php @@ -0,0 +1,34 @@ +user() !== null; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'workspace_name' => ['required', 'string', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'workspace_name.required' => 'Enter a workspace name to create it on this connection.', + ]; + } +} diff --git a/app/Models/InstanceConnection.php b/app/Models/InstanceConnection.php index ef63731..3faba20 100644 --- a/app/Models/InstanceConnection.php +++ b/app/Models/InstanceConnection.php @@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'user_id', 'name', 'kind', 'base_url', + 'active_workspace_id', 'session_context', 'last_authenticated_at', 'last_used_at', @@ -47,6 +49,22 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + /** + * @return BelongsTo + */ + public function activeWorkspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'active_workspace_id'); + } + + /** + * @return HasMany + */ + public function workspaces(): HasMany + { + return $this->hasMany(Workspace::class); + } + protected function summary(): Attribute { return Attribute::make( diff --git a/app/Models/SurrealWorkspace.php b/app/Models/SurrealWorkspace.php new file mode 100644 index 0000000..c508cc6 --- /dev/null +++ b/app/Models/SurrealWorkspace.php @@ -0,0 +1,27 @@ + 'desktop-preview', + 'name' => 'Desktop Preview Workspace', + 'summary' => 'A Surreal-backed workspace record created to prove the first Katra persistence layer.', + 'status' => 'active', + ]); + } +} diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index e3fbb33..514cf67 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -2,26 +2,23 @@ namespace App\Models; +use Database\Factories\WorkspaceFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; -#[Fillable(['id', 'name', 'summary', 'status'])] -class Workspace extends SurrealModel +#[Fillable(['instance_connection_id', 'name', 'slug', 'summary'])] +class Workspace extends Model { - protected $table = 'workspaces'; + /** @use HasFactory */ + use HasFactory; - public static function desktopPreview(): self + /** + * @return BelongsTo + */ + public function instanceConnection(): BelongsTo { - $workspace = static::find('desktop-preview'); - - if ($workspace !== null) { - return $workspace; - } - - return static::create([ - 'id' => 'desktop-preview', - 'name' => 'Desktop Preview Workspace', - 'summary' => 'A Surreal-backed workspace record created to prove the first Katra persistence layer.', - 'status' => 'active', - ]); + return $this->belongsTo(InstanceConnection::class); } } diff --git a/app/Support/Connections/InstanceConnectionManager.php b/app/Support/Connections/InstanceConnectionManager.php index 08f564a..2b1ed9b 100644 --- a/app/Support/Connections/InstanceConnectionManager.php +++ b/app/Support/Connections/InstanceConnectionManager.php @@ -4,13 +4,17 @@ use App\Models\InstanceConnection; use App\Models\User; +use App\Models\Workspace; use Illuminate\Contracts\Session\Session; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Str; class InstanceConnectionManager { private const ACTIVE_CONNECTION_SESSION_KEY = 'instance_connection.active_id'; + private const DEFAULT_WORKSPACE_NAME = 'General'; + /** * @return Collection */ @@ -125,6 +129,69 @@ public function ensureCurrentInstanceConnection(User $user, string $currentInsta return $connection; } + /** + * @return Collection + */ + public function workspacesFor(InstanceConnection $connection): Collection + { + return $connection->workspaces() + ->orderBy('name') + ->get() + ->values(); + } + + public function activeWorkspaceFor(InstanceConnection $connection): Workspace + { + $workspaces = $this->workspacesFor($connection); + $activeWorkspace = $workspaces->firstWhere('id', $connection->active_workspace_id); + + if (! $activeWorkspace instanceof Workspace) { + $activeWorkspace = $workspaces->first() ?? $this->createWorkspace($connection, [ + 'name' => self::DEFAULT_WORKSPACE_NAME, + ]); + } + + if ((int) $connection->active_workspace_id !== (int) $activeWorkspace->getKey()) { + $connection->forceFill([ + 'active_workspace_id' => $activeWorkspace->getKey(), + ])->save(); + } + + return $activeWorkspace; + } + + /** + * @param array{name: string} $attributes + */ + public function createWorkspace(InstanceConnection $connection, array $attributes): Workspace + { + $workspaceName = trim($attributes['name']); + $workspace = $connection->workspaces()->create([ + 'name' => $workspaceName, + 'slug' => $this->nextWorkspaceSlug($connection, $workspaceName), + 'summary' => $this->workspaceSummary($connection, $workspaceName), + ]); + + $connection->forceFill([ + 'active_workspace_id' => $workspace->getKey(), + ])->save(); + + return $workspace; + } + + public function activateWorkspace(Workspace $workspace, Session $session): void + { + $connection = $workspace->instanceConnection; + + $this->activate($connection, $session); + + if ((int) $connection->active_workspace_id !== (int) $workspace->getKey()) { + $connection->forceFill([ + 'active_workspace_id' => $workspace->getKey(), + ])->save(); + } + } + /** * @param array $sessionContext */ @@ -195,4 +262,35 @@ private function applicationConnectionName(): string { return (string) config('app.name', 'Katra'); } + + private function nextWorkspaceSlug(InstanceConnection $connection, string $workspaceName): string + { + $baseSlug = Str::slug($workspaceName); + $baseSlug = $baseSlug !== '' ? $baseSlug : 'workspace'; + $slug = $baseSlug; + $suffix = 2; + + while ($connection->workspaces()->where('slug', $slug)->exists()) { + $slug = $baseSlug.'-'.$suffix; + $suffix++; + } + + return $slug; + } + + private function workspaceSummary(InstanceConnection $connection, string $workspaceName): string + { + if ($connection->kind === InstanceConnection::KIND_SERVER) { + return sprintf( + '%s is a shared workspace on %s for rooms, chats, and linked context.', + $workspaceName, + $connection->name, + ); + } + + return sprintf( + '%s is a workspace on this Katra instance for conversations, tasks, and linked work.', + $workspaceName, + ); + } } diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php new file mode 100644 index 0000000..6fa6ec5 --- /dev/null +++ b/database/factories/WorkspaceFactory.php @@ -0,0 +1,31 @@ + + */ +class WorkspaceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->words(2, true); + + return [ + 'instance_connection_id' => InstanceConnection::factory(), + 'name' => str($name)->title()->value(), + 'slug' => Str::slug($name), + 'summary' => fake()->sentence(), + ]; + } +} diff --git a/database/migrations/2026_03_27_063640_create_workspaces_table.php b/database/migrations/2026_03_27_063640_create_workspaces_table.php new file mode 100644 index 0000000..a3c9f08 --- /dev/null +++ b/database/migrations/2026_03_27_063640_create_workspaces_table.php @@ -0,0 +1,45 @@ +getDriverName(); + + if ($driver === 'surreal' && Schema::hasTable('workspaces')) { + Schema::drop('workspaces'); + } + + Schema::create('workspaces', function (Blueprint $table) use ($driver) { + $table->id(); + if ($driver === 'surreal') { + $table->unsignedBigInteger('instance_connection_id'); + } else { + $table->foreignId('instance_connection_id')->constrained()->cascadeOnDelete(); + } + $table->string('name'); + $table->string('slug'); + $table->text('summary')->nullable(); + $table->timestamps(); + + if ($driver !== 'surreal') { + $table->unique(['instance_connection_id', 'slug'], 'workspaces_connection_slug_unique'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspaces'); + } +}; diff --git a/database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php b/database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php new file mode 100644 index 0000000..46158e7 --- /dev/null +++ b/database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php @@ -0,0 +1,44 @@ +getDriverName(); + + Schema::table('instance_connections', function (Blueprint $table) use ($driver) { + if ($driver === 'surreal') { + $table->unsignedBigInteger('active_workspace_id')->nullable()->after('base_url'); + } else { + $table->foreignId('active_workspace_id') + ->nullable() + ->after('base_url') + ->constrained('workspaces') + ->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $driver = Schema::getConnection()->getDriverName(); + + Schema::table('instance_connections', function (Blueprint $table) use ($driver) { + if ($driver !== 'surreal') { + $table->dropForeign(['active_workspace_id']); + } + + $table->dropColumn('active_workspace_id'); + }); + } +}; diff --git a/resources/views/components/desktop/nav-item.blade.php b/resources/views/components/desktop/nav-item.blade.php index a429a48..d4c544e 100644 --- a/resources/views/components/desktop/nav-item.blade.php +++ b/resources/views/components/desktop/nav-item.blade.php @@ -5,6 +5,8 @@ 'active' => false, 'muted' => false, 'href' => null, + 'action' => null, + 'method' => 'POST', 'tone' => 'room', ]) @@ -30,6 +32,8 @@ $muted => 'shell-surface shell-text-faint', default => 'shell-surface shell-text-soft', }; + + $actionMethod = strtoupper((string) $method); @endphp @if ($href) @@ -42,6 +46,23 @@ {{ $meta }} @endif +@elseif ($action) +
+ @csrf + @if ($actionMethod !== 'POST') + @method($actionMethod) + @endif + + +
@else
class(['flex items-center gap-3 rounded-2xl px-3 py-2', $containerClasses]) }}> {{ $prefix }} diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 6f84273..06b7308 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -28,16 +28,16 @@ @php $searchResults = [ [ - 'label' => 'Conversations', + 'label' => 'Workspaces', 'items' => [ - ['title' => '# design-room', 'meta' => 'Room', 'summary' => 'Current active room in '.$activeWorkspace['label'].'.'], - ['title' => '# shell-studies', 'meta' => 'Room', 'summary' => 'Design-focused room for layout and navigation work.'], + ['title' => $activeWorkspace['label'], 'meta' => 'Workspace', 'summary' => 'Current active workspace on '.$activeConnection->name.'.'], + ['title' => 'General', 'meta' => 'Workspace', 'summary' => 'Default workspace for the current connection.'], ], ], [ 'label' => 'People and agents', 'items' => [ - ['title' => 'Derek Bourgeois', 'meta' => 'Human', 'summary' => 'Direct conversation and workspace owner context.'], + ['title' => $viewerName, 'meta' => 'Human', 'summary' => 'Direct conversation and workspace owner context.'], ['title' => 'Planner Agent', 'meta' => 'Worker', 'summary' => 'Planning and structuring support for the active room.'], ], ], @@ -103,12 +103,26 @@ class="shell-icon-button inline-flex h-7 w-7 items-center justify-center rounded
- - @foreach ($favoriteLinks as $item) - + + @foreach ($workspaceLinks as $item) + @endforeach + @if ($favoritesEnabled) + + @foreach ($favoriteLinks as $item) + + @endforeach + + @endif + @foreach ($roomLinks as $item) @@ -219,10 +233,10 @@ class="shell-icon-button inline-flex h-8 w-8 items-center justify-center rounded
-

Conversation

-

{{ $activeWorkspace['room'] }}

+

Workspace

+

{{ $activeWorkspace['label'] }}

- Shared room for people, models, and agents working inside {{ $activeWorkspace['label'] }}. + {{ $activeWorkspace['summary'] }}

@@ -276,8 +290,8 @@ class="shell-accent-soft shell-text hidden items-center gap-2 rounded-full px-3 data-message-input rows="1" class="shell-text min-h-[56px] w-full resize-none bg-transparent pt-1 text-[15px] leading-7 outline-none placeholder:text-[color:var(--shell-text-faint)]" - placeholder="Message {{ $activeWorkspace['room'] }}" - aria-label="Message {{ $activeWorkspace['room'] }}" + placeholder="Message {{ $activeWorkspace['label'] }}" + aria-label="Message {{ $activeWorkspace['label'] }}" >
@@ -651,6 +665,44 @@ class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-no
+ +
+ @csrf + +
+ + +
+ +

+ Workspaces keep each connection organized by project. +

+ + @if ($errors->has('workspace_name')) +
+

{{ $errors->first('workspace_name') }}

+
+ @endif + +
+ + +
+
+
+ @foreach ($connectionLinks as $item) name('connections.activate'); Route::get('/connections/{instanceConnection}/connect', [InstanceConnectionController::class, 'connect'])->name('connections.connect'); Route::post('/connections/{instanceConnection}/connect', [InstanceConnectionController::class, 'authenticate'])->name('connections.authenticate'); + Route::post('/workspaces', [WorkspaceController::class, 'store'])->name('workspaces.store'); + Route::post('/workspaces/{workspace}/activate', [WorkspaceController::class, 'activate'])->name('workspaces.activate'); }); diff --git a/tests/Feature/DesktopShellTest.php b/tests/Feature/DesktopShellTest.php index ba3f8c4..fb2aaf4 100644 --- a/tests/Feature/DesktopShellTest.php +++ b/tests/Feature/DesktopShellTest.php @@ -2,6 +2,7 @@ use App\Models\InstanceConnection; use App\Models\User; +use App\Models\Workspace; use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs; @@ -34,6 +35,25 @@ function desktopShellUser(): User configureDesktopShell(); $user = desktopShellUser(); + $currentConnection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + ]); + $activeWorkspace = Workspace::factory()->for($currentConnection)->create([ + 'name' => 'Product Atlas', + 'slug' => 'product-atlas', + 'summary' => 'Product Atlas is a workspace on this instance for conversations, tasks, and linked work.', + ]); + Workspace::factory()->for($currentConnection)->create([ + 'name' => 'General', + 'slug' => 'general', + ]); + + $currentConnection->forceFill([ + 'active_workspace_id' => $activeWorkspace->getKey(), + ])->save(); InstanceConnection::factory()->for($user)->create([ 'name' => 'Relay Cloud', @@ -48,14 +68,17 @@ function desktopShellUser(): User get('/') ->assertSuccessful() ->assertSee('Katra') - ->assertSee('Favorites') + ->assertSee('Workspaces') ->assertSee('Rooms') ->assertSee('Chats') + ->assertSee('Create workspace') ->assertSee('Create room') ->assertSee('Create chat') + ->assertSee('Product Atlas') + ->assertSee('General') ->assertSee('Planner Agent') ->assertSee('Research Model') - ->assertSee('# design-room') + ->assertSee('# general') ->assertSee('Connections') ->assertSee('Add a server') ->assertSee('Connection name') @@ -68,6 +91,7 @@ function desktopShellUser(): User ->assertSee('Expand sidebar') ->assertSee('Search conversations, people, and nodes') ->assertSee('People and agents') + ->assertSee('Workspace') ->assertSee('Open context panel') ->assertSee('Close context panel') ->assertSee('Pin context panel') @@ -84,9 +108,9 @@ function desktopShellUser(): User ->assertSee('Attach file') ->assertSee('Toggle voice mode') ->assertSee('Send message') - ->assertSee('Message # design-room') + ->assertSee('Message Product Atlas') ->assertSee('Voice mode selected') - ->assertSee('Tighten the room layout, spacing, and navigation so the shell feels like an app instead of a staged page.') + ->assertSee('Product Atlas is a workspace on this instance for conversations, tasks, and linked work.') ->assertSee('Derek Bourgeois') ->assertSee('derek@katra.io') ->assertSee('Profile settings') @@ -96,9 +120,6 @@ function desktopShellUser(): User ->assertSee('Dark') ->assertSee('System') ->assertSee('Log out') - ->assertDontSee('Create workspace') - ->assertDontSee('Workspace name') - ->assertDontSee('Workspaces') ->assertDontSee('desktop mvp preview') ->assertDontSee('composer native:dev') ->assertDontSee('Surreal Foundation') @@ -110,6 +131,7 @@ function desktopShellUser(): User ->assertDontSee('First note') ->assertDontSee('Views') ->assertDontSee('Workspace navigation pilot') + ->assertDontSee('Favorites') ->assertDontSee('Message input will live here.'); }); @@ -133,8 +155,8 @@ function desktopShellUser(): User get('/') ->assertSuccessful() ->assertSee('Katra') - ->assertSee('# design-room') - ->assertDontSee('Workspace navigation'); + ->assertSee('General') + ->assertSee('Workspaces'); }); test('the desktop shell can render a saved server connection as the active connection', function () { @@ -154,6 +176,15 @@ function desktopShellUser(): User ], ], ]); + $workspace = Workspace::factory()->for($connection)->create([ + 'name' => 'Relay Launch', + 'slug' => 'relay-launch', + 'summary' => 'Relay Launch is the active workspace on Relay Cloud for shared orchestration, worker presence, and linked team context.', + ]); + + $connection->forceFill([ + 'active_workspace_id' => $workspace->getKey(), + ])->save(); actingAs($user) ->withSession(['instance_connection.active_id' => $connection->getKey()]); @@ -162,7 +193,8 @@ function desktopShellUser(): User ->assertSuccessful() ->assertSee('Relay Cloud') ->assertSee('Connections') - ->assertSee('# relay-ops') + ->assertSee('Relay Launch') + ->assertSee('# general') ->assertSee('Ops Agent') ->assertSee('Routing Agent') ->assertSee('Relay Operator') diff --git a/tests/Feature/DesktopUiFeatureFlagTest.php b/tests/Feature/DesktopUiFeatureFlagTest.php index ed3c8d9..a460f6f 100644 --- a/tests/Feature/DesktopUiFeatureFlagTest.php +++ b/tests/Feature/DesktopUiFeatureFlagTest.php @@ -86,7 +86,8 @@ $this->get('/') ->assertSuccessful() - ->assertSee('# design-room') + ->assertSee('General') + ->assertSee('# general') ->assertSee('Katra'); }); @@ -106,6 +107,6 @@ $this->get('/') ->assertSuccessful() ->assertSee('The MVP workspace shell is currently hidden.') - ->assertDontSee('# design-room') + ->assertDontSee('# general') ->assertDontSee('Notes'); }); diff --git a/tests/Feature/InstanceConnectionManagementTest.php b/tests/Feature/InstanceConnectionManagementTest.php index 4879869..a6fc099 100644 --- a/tests/Feature/InstanceConnectionManagementTest.php +++ b/tests/Feature/InstanceConnectionManagementTest.php @@ -2,6 +2,7 @@ use App\Models\InstanceConnection; use App\Models\User; +use App\Models\Workspace; use App\Support\Connections\InstanceConnectionManager; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Client\Request as HttpRequest; @@ -248,6 +249,14 @@ ], ], ]); + $workspace = Workspace::factory()->for($connection)->create([ + 'name' => 'Relay Launch', + 'slug' => 'relay-launch', + ]); + + $connection->forceFill([ + 'active_workspace_id' => $workspace->getKey(), + ])->save(); actingAs($user)->withSession([ 'instance_connection.active_id' => $connection->getKey(), @@ -256,7 +265,8 @@ get(route('home')) ->assertSuccessful() ->assertSee('Relay Cloud') - ->assertSee('# relay-ops') + ->assertSee('Relay Launch') + ->assertSee('# general') ->assertSee('Ops Agent') ->assertSee('Relay Ops') ->assertSee('ops@relay.devoption.test'); diff --git a/tests/Feature/SurrealWorkspaceModelTest.php b/tests/Feature/SurrealWorkspaceModelTest.php index c091916..e635438 100644 --- a/tests/Feature/SurrealWorkspaceModelTest.php +++ b/tests/Feature/SurrealWorkspaceModelTest.php @@ -1,6 +1,6 @@ forgetInstance(SurrealRuntimeManager::class); app()->forgetInstance(SurrealDocumentStore::class); - $workspace = Workspace::create([ + $workspace = SurrealWorkspace::create([ 'name' => 'Download Preview Workspace', 'summary' => 'Proves that Katra can persist through the first Surreal-backed model layer.', 'status' => 'draft', ]); - expect($workspace->id)->toStartWith('workspaces:') + expect($workspace->id)->toStartWith('workspace_previews:') ->and($workspace->exists)->toBeTrue(); - $fetchedWorkspace = Workspace::find($workspace->id); + $fetchedWorkspace = SurrealWorkspace::find($workspace->id); expect($fetchedWorkspace)->not->toBeNull() ->and($fetchedWorkspace?->name)->toBe('Download Preview Workspace') @@ -62,21 +62,21 @@ $workspace->summary = 'Updated through the Surreal-backed save flow.'; $workspace->save(); - $updatedWorkspace = Workspace::find($workspace->id); + $updatedWorkspace = SurrealWorkspace::find($workspace->id); expect($updatedWorkspace)->not->toBeNull() ->and($updatedWorkspace?->status)->toBe('active') ->and($updatedWorkspace?->summary)->toBe('Updated through the Surreal-backed save flow.'); - expect(Workspace::all())->toHaveCount(1); + expect(SurrealWorkspace::all())->toHaveCount(1); - $collection = Workspace::find([$workspace->id]); + $collection = SurrealWorkspace::find([$workspace->id]); expect($collection)->toHaveCount(1) ->and($collection->first()?->id)->toBe($workspace->id); expect($workspace->delete())->toBeTrue() - ->and(Workspace::find($workspace->id))->toBeNull(); + ->and(SurrealWorkspace::find($workspace->id))->toBeNull(); } finally { if (isset($server['process'])) { $server['process']->stop(1); @@ -119,16 +119,16 @@ app()->forgetInstance(SurrealRuntimeManager::class); app()->forgetInstance(SurrealDocumentStore::class); - $workspace = Workspace::desktopPreview(); + $workspace = SurrealWorkspace::desktopPreview(); - expect($workspace->id)->toBe('workspaces:desktop-preview') + expect($workspace->id)->toBe('workspace_previews:desktop-preview') ->and($workspace->name)->toBe('Desktop Preview Workspace') ->and($workspace->status)->toBe('active'); - $fetchedWorkspace = Workspace::find('desktop-preview'); + $fetchedWorkspace = SurrealWorkspace::find('desktop-preview'); expect($fetchedWorkspace)->not->toBeNull() - ->and($fetchedWorkspace?->id)->toBe('workspaces:desktop-preview') + ->and($fetchedWorkspace?->id)->toBe('workspace_previews:desktop-preview') ->and($fetchedWorkspace?->summary)->toContain('Surreal-backed workspace record'); } finally { if (isset($server['process'])) { diff --git a/tests/Feature/WorkspaceManagementTest.php b/tests/Feature/WorkspaceManagementTest.php new file mode 100644 index 0000000..397b789 --- /dev/null +++ b/tests/Feature/WorkspaceManagementTest.php @@ -0,0 +1,166 @@ +create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + 'active_workspace_id' => null, + ]); + + $workspace = app(InstanceConnectionManager::class)->activeWorkspaceFor($connection); + + expect($workspace->name)->toBe('General') + ->and($workspace->slug)->toBe('general') + ->and($connection->fresh()->active_workspace_id)->toBe($workspace->getKey()) + ->and($connection->workspaces()->count())->toBe(1); +}); + +test('an authenticated user can create a workspace for the active connection', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + ]); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + post(route('workspaces.store'), [ + 'workspace_name' => 'Project Atlas', + ])->assertRedirect(route('home')); + + $workspace = $connection->workspaces()->where('slug', 'project-atlas')->first(); + + expect($workspace)->not()->toBeNull() + ->and($workspace?->name)->toBe('Project Atlas') + ->and($connection->fresh()->active_workspace_id)->toBe($workspace?->getKey()); +}); + +test('an authenticated user can switch the active workspace for the active connection', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + ]); + $generalWorkspace = Workspace::factory()->for($connection)->create([ + 'name' => 'General', + 'slug' => 'general', + ]); + $projectWorkspace = Workspace::factory()->for($connection)->create([ + 'name' => 'Project Atlas', + 'slug' => 'project-atlas', + ]); + + $connection->forceFill([ + 'active_workspace_id' => $generalWorkspace->getKey(), + ])->save(); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + post(route('workspaces.activate', $projectWorkspace)) + ->assertRedirect(route('home')); + + expect($connection->fresh()->active_workspace_id)->toBe($projectWorkspace->getKey()); +}); + +test('each connection keeps its own active workspace', function () { + config()->set('pennant.default', 'array'); + config()->set('surreal.autostart', false); + config()->set('surreal.host', '127.0.0.1'); + config()->set('surreal.port', 18999); + config()->set('surreal.endpoint', 'ws://127.0.0.1:18999'); + config()->set('surreal.binary', 'surreal-missing-binary-for-workspace-test'); + + $user = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ]); + + $localConnection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now()->subMinute(), + ]); + $serverConnection = InstanceConnection::factory()->for($user)->create([ + 'name' => 'Katra Server', + 'kind' => InstanceConnection::KIND_SERVER, + 'base_url' => 'https://katra-server.test', + 'last_authenticated_at' => now(), + 'last_used_at' => now(), + 'session_context' => [ + 'user' => [ + 'name' => 'Ops Bourgeois', + 'email' => 'ops@relay.devoption.test', + ], + ], + ]); + + $localWorkspace = Workspace::factory()->for($localConnection)->create([ + 'name' => 'Product Atlas', + 'slug' => 'product-atlas', + ]); + $serverWorkspace = Workspace::factory()->for($serverConnection)->create([ + 'name' => 'Relay Launch', + 'slug' => 'relay-launch', + ]); + + $localConnection->forceFill([ + 'active_workspace_id' => $localWorkspace->getKey(), + ])->save(); + $serverConnection->forceFill([ + 'active_workspace_id' => $serverWorkspace->getKey(), + ])->save(); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $localConnection->getKey(), + ]); + + get(route('home')) + ->assertSuccessful() + ->assertSee('Product Atlas') + ->assertDontSee('Relay Launch'); + + post(route('connections.activate', $serverConnection)) + ->assertRedirect(route('home')); + + get(route('home')) + ->assertSuccessful() + ->assertSee('Relay Launch'); + + post(route('connections.activate', $localConnection)) + ->assertRedirect(route('home')); + + get(route('home')) + ->assertSuccessful() + ->assertSee('Product Atlas') + ->assertDontSee('Relay Launch'); +}); + +test('an authenticated user cannot activate another users workspace', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + actingAs($user); + + post(route('workspaces.activate', $workspace)) + ->assertNotFound(); +}); From 6c5de28e374bb6e3a2c342c96755ee046cf2390f Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Fri, 27 Mar 2026 09:46:48 -0400 Subject: [PATCH 2/2] fix: address workspace review feedback --- app/Http/Controllers/HomeController.php | 2 +- app/Http/Requests/StoreWorkspaceRequest.php | 3 ++- app/Models/Workspace.php | 2 ++ .../Connections/InstanceConnectionManager.php | 7 +++++-- ...6_03_27_063640_create_workspaces_table.php | 8 ++------ ...space_id_to_instance_connections_table.php | 2 +- tests/Feature/WorkspaceManagementTest.php | 19 +++++++++++++++++++ 7 files changed, 32 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 5a5f1ab..31bc0f4 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -510,8 +510,8 @@ public function __invoke( } $connections = $connectionManager->connectionsFor($request->user()); - $activeWorkspaceModel = $connectionManager->activeWorkspaceFor($activeConnection); $workspaces = $connectionManager->workspacesFor($activeConnection); + $activeWorkspaceModel = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces); $activeWorkspace = $this->activeWorkspaceState($activeConnection, $activeWorkspaceModel, $localReady); return view('welcome', [ diff --git a/app/Http/Requests/StoreWorkspaceRequest.php b/app/Http/Requests/StoreWorkspaceRequest.php index 39f9110..ab94673 100644 --- a/app/Http/Requests/StoreWorkspaceRequest.php +++ b/app/Http/Requests/StoreWorkspaceRequest.php @@ -18,7 +18,7 @@ public function authorize(): bool public function rules(): array { return [ - 'workspace_name' => ['required', 'string', 'max:255'], + 'workspace_name' => ['required', 'string', 'max:255', 'regex:/\\S/'], ]; } @@ -29,6 +29,7 @@ public function messages(): array { return [ 'workspace_name.required' => 'Enter a workspace name to create it on this connection.', + 'workspace_name.regex' => 'Workspace names cannot be blank.', ]; } } diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 514cf67..8e00675 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -14,6 +14,8 @@ class Workspace extends Model /** @use HasFactory */ use HasFactory; + protected $table = 'connection_workspaces'; + /** * @return BelongsTo */ diff --git a/app/Support/Connections/InstanceConnectionManager.php b/app/Support/Connections/InstanceConnectionManager.php index 2b1ed9b..4a478e1 100644 --- a/app/Support/Connections/InstanceConnectionManager.php +++ b/app/Support/Connections/InstanceConnectionManager.php @@ -140,9 +140,12 @@ public function workspacesFor(InstanceConnection $connection): Collection ->values(); } - public function activeWorkspaceFor(InstanceConnection $connection): Workspace + /** + * @param Collection|null $workspaces + */ + public function activeWorkspaceFor(InstanceConnection $connection, ?Collection $workspaces = null): Workspace { - $workspaces = $this->workspacesFor($connection); + $workspaces ??= $this->workspacesFor($connection); $activeWorkspace = $workspaces->firstWhere('id', $connection->active_workspace_id); if (! $activeWorkspace instanceof Workspace) { diff --git a/database/migrations/2026_03_27_063640_create_workspaces_table.php b/database/migrations/2026_03_27_063640_create_workspaces_table.php index a3c9f08..ab57aca 100644 --- a/database/migrations/2026_03_27_063640_create_workspaces_table.php +++ b/database/migrations/2026_03_27_063640_create_workspaces_table.php @@ -13,11 +13,7 @@ public function up(): void { $driver = Schema::getConnection()->getDriverName(); - if ($driver === 'surreal' && Schema::hasTable('workspaces')) { - Schema::drop('workspaces'); - } - - Schema::create('workspaces', function (Blueprint $table) use ($driver) { + Schema::create('connection_workspaces', function (Blueprint $table) use ($driver) { $table->id(); if ($driver === 'surreal') { $table->unsignedBigInteger('instance_connection_id'); @@ -40,6 +36,6 @@ public function up(): void */ public function down(): void { - Schema::dropIfExists('workspaces'); + Schema::dropIfExists('connection_workspaces'); } }; diff --git a/database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php b/database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php index 46158e7..fb2ff55 100644 --- a/database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php +++ b/database/migrations/2026_03_27_063705_add_active_workspace_id_to_instance_connections_table.php @@ -20,7 +20,7 @@ public function up(): void $table->foreignId('active_workspace_id') ->nullable() ->after('base_url') - ->constrained('workspaces') + ->constrained('connection_workspaces') ->nullOnDelete(); } }); diff --git a/tests/Feature/WorkspaceManagementTest.php b/tests/Feature/WorkspaceManagementTest.php index 397b789..da41d42 100644 --- a/tests/Feature/WorkspaceManagementTest.php +++ b/tests/Feature/WorkspaceManagementTest.php @@ -50,6 +50,25 @@ ->and($connection->fresh()->active_workspace_id)->toBe($workspace?->getKey()); }); +test('an authenticated user cannot create a workspace with only whitespace in the name', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'name' => 'Katra', + 'base_url' => 'https://katra.test', + ]); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + post(route('workspaces.store'), [ + 'workspace_name' => ' ', + ]) + ->assertSessionHasErrors('workspace_name'); + + expect($connection->workspaces()->count())->toBe(0); +}); + test('an authenticated user can switch the active workspace for the active connection', function () { $user = User::factory()->create(); $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([