diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php new file mode 100644 index 0000000..f7e24fd --- /dev/null +++ b/app/Http/Controllers/ChatController.php @@ -0,0 +1,57 @@ +activeConnectionFor( + $request->user(), + $request->root(), + $request->session(), + ); + + if ($activeConnection->kind === InstanceConnection::KIND_SERVER && ! $activeConnection->is_authenticated) { + return to_route('connections.connect', $activeConnection); + } + + $workspaces = $connectionManager->workspacesFor($activeConnection); + $activeWorkspace = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces); + $viewerIdentity = $viewerIdentityResolver->resolve($request->user(), $activeConnection); + + $chatManager->createChat($activeWorkspace, $request->user(), $viewerIdentity, [ + 'name' => $request->validated('chat_name'), + 'kind' => $request->validated('chat_kind'), + ]); + + return to_route('home'); + } + + public function activate( + Request $request, + WorkspaceChat $workspaceChat, + WorkspaceChatManager $chatManager, + ): RedirectResponse { + if ((int) $workspaceChat->workspace->instanceConnection->user_id !== (int) $request->user()->getKey()) { + abort(404); + } + + $chatManager->activateChat($workspaceChat); + + return to_route('home'); + } +} diff --git a/app/Http/Controllers/ChatMessageController.php b/app/Http/Controllers/ChatMessageController.php new file mode 100644 index 0000000..ecc16d7 --- /dev/null +++ b/app/Http/Controllers/ChatMessageController.php @@ -0,0 +1,31 @@ +workspace->instanceConnection->user_id !== (int) $request->user()->getKey()) { + abort(404); + } + + $viewerIdentity = $viewerIdentityResolver->resolve($request->user(), $workspaceChat->workspace->instanceConnection); + + $chatManager->createMessage($workspaceChat, $request->user(), $viewerIdentity, [ + 'body' => $request->validated('message_body'), + ]); + + return to_route('home'); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 31bc0f4..d9320a2 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -5,10 +5,14 @@ use App\Features\Desktop\MvpShell; use App\Models\InstanceConnection; use App\Models\SurrealWorkspace; -use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceChat; +use App\Models\WorkspaceChatMessage; +use App\Models\WorkspaceChatParticipant; use App\Services\Surreal\SurrealRuntimeManager; +use App\Support\Chats\WorkspaceChatManager; use App\Support\Connections\InstanceConnectionManager; +use App\Support\Connections\ViewerIdentityResolver; use App\Support\Features\DesktopUi; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Http\RedirectResponse; @@ -305,65 +309,63 @@ private function roomLinks(InstanceConnection $activeConnection, string $activeR } /** - * @param array $participants - * @return array + * @param EloquentCollection $chats + * @return array */ - private function chatLinks(array $participants, string $viewerName): array + private function chatLinks(EloquentCollection $chats, WorkspaceChat $activeChat): array { - $links = [ - ['label' => $viewerName, 'prefix' => substr($viewerName, 0, 1), 'tone' => 'human'], - ]; - - foreach ($participants as $participant) { - if ($participant['meta'] === 'Human') { - continue; - } - - $links[] = [ - 'label' => $participant['label'], - 'prefix' => '@', - 'tone' => 'bot', - ]; - } - - return array_slice($links, 0, 3); + return $chats + ->map(fn (WorkspaceChat $chat): array => [ + 'label' => $chat->name, + 'active' => (int) $chat->getKey() === (int) $activeChat->getKey(), + 'prefix' => $chat->kind === WorkspaceChat::KIND_DIRECT ? '@' : strtoupper(substr($chat->name, 0, 1)), + 'tone' => $chat->kind === WorkspaceChat::KIND_DIRECT ? 'human' : 'room', + 'action' => (int) $chat->getKey() === (int) $activeChat->getKey() ? null : route('chats.activate', $chat), + ]) + ->values() + ->all(); } /** - * @param array $participants - * @return array + * @return array */ - private function chatContacts(array $participants, string $viewerName): array + private function chatParticipants(WorkspaceChat $chat): array { - $contacts = [[ - 'label' => $viewerName, - 'value' => str($viewerName)->slug()->value(), - 'prefix' => substr($viewerName, 0, 1), - 'tone' => 'human', - 'subtitle' => 'Human', - ]]; - - foreach ($participants as $participant) { - if ($participant['meta'] === 'Human') { - continue; - } + return $chat->participants + ->map(fn (WorkspaceChatParticipant $participant): array => [ + 'label' => $participant->display_name, + 'meta' => $participant->participant_type === WorkspaceChatParticipant::TYPE_AGENT ? 'Agent' : 'Human', + ]) + ->values() + ->all(); + } - $contacts[] = [ - 'label' => $participant['label'], - 'value' => str($participant['label'])->slug()->value(), - 'prefix' => '@', - 'tone' => 'bot', - 'subtitle' => $participant['meta'], - ]; - } + /** + * @return array + */ + private function chatMessages(WorkspaceChat $chat): array + { + return $chat->messages() + ->orderBy('created_at') + ->orderBy('id') + ->get() + ->map(function (WorkspaceChatMessage $message): array { + $role = match ($message->sender_type) { + WorkspaceChatMessage::SENDER_AGENT => 'Agent', + WorkspaceChatMessage::SENDER_SYSTEM => 'System', + default => 'Human', + }; - return $contacts; + return [ + 'speaker' => $message->sender_name, + 'role' => $role, + 'body' => $message->body, + 'meta' => $message->created_at?->diffForHumans() ?? 'Just now', + 'tone' => $role === 'Human' ? 'plain' : 'accent', + ]; + }) + ->values() + ->all(); } /** @@ -480,6 +482,8 @@ public function __invoke( Request $request, SurrealRuntimeManager $runtimeManager, InstanceConnectionManager $connectionManager, + ViewerIdentityResolver $viewerIdentityResolver, + WorkspaceChatManager $chatManager, ): View|RedirectResponse { $localReady = false; $desktopUiStates = DesktopUi::states(); @@ -502,7 +506,7 @@ public function __invoke( $request->session(), ); - $viewerIdentity = $this->viewerIdentity($request->user(), $activeConnection); + $viewerIdentity = $viewerIdentityResolver->resolve($request->user(), $activeConnection); $viewerName = $viewerIdentity['name']; if ($activeConnection->kind === InstanceConnection::KIND_SERVER && ! $activeConnection->is_authenticated) { @@ -513,6 +517,10 @@ public function __invoke( $workspaces = $connectionManager->workspacesFor($activeConnection); $activeWorkspaceModel = $connectionManager->activeWorkspaceFor($activeConnection, $workspaces); $activeWorkspace = $this->activeWorkspaceState($activeConnection, $activeWorkspaceModel, $localReady); + $activeChatModel = $chatManager->activeChatFor($activeWorkspaceModel, $request->user(), $viewerIdentity); + $chats = $chatManager->chatsFor($activeWorkspaceModel); + $participants = $this->chatParticipants($activeChatModel); + $messages = $this->chatMessages($activeChatModel); return view('welcome', [ 'mvpShellEnabled' => $mvpShellEnabled, @@ -521,13 +529,13 @@ public function __invoke( 'favoritesEnabled' => self::FAVORITES_ENABLED, 'workspaceLinks' => $this->workspaceLinks($workspaces, $activeWorkspaceModel), 'activeWorkspace' => $activeWorkspace, + 'activeChat' => $activeChatModel, 'favoriteLinks' => $this->favoriteLinks($activeWorkspace, $viewerName), 'roomLinks' => $this->roomLinks($activeConnection, $activeWorkspace['room']), - 'chatLinks' => $this->chatLinks($activeWorkspace['participants'], $viewerName), - 'chatContacts' => $this->chatContacts($activeWorkspace['participants'], $viewerName), + 'chatLinks' => $this->chatLinks($chats, $activeChatModel), 'conversationNodeTabs' => $this->conversationNodeTabs($activeWorkspace), - 'messages' => $activeWorkspace['messages'], - 'participants' => $activeWorkspace['participants'], + 'messages' => $messages, + 'participants' => $participants, 'viewerName' => $viewerIdentity['name'], 'viewerEmail' => $viewerIdentity['email'], 'viewerInitials' => $viewerIdentity['initials'], @@ -551,58 +559,4 @@ private function connectionPrefix(InstanceConnection $connection): string { return strtoupper(substr($connection->name, 0, 1)); } - - /** - * @return array{name: string, email: string, initials: string} - */ - private function viewerIdentity(?User $viewer, InstanceConnection $activeConnection): array - { - $remoteIdentity = $activeConnection->kind === InstanceConnection::KIND_SERVER - ? data_get($activeConnection->session_context, 'user') - : null; - $remoteEmail = $activeConnection->kind === InstanceConnection::KIND_SERVER - ? data_get($activeConnection->session_context, 'email') - : null; - $remoteName = data_get($remoteIdentity, 'name'); - - if ((! is_string($remoteName) || $remoteName === '') && is_string($remoteEmail) && $remoteEmail !== '') { - $remoteName = $this->nameFromEmail($remoteEmail); - } - - $name = $remoteName - ?: $viewer?->name - ?: 'Derek Bourgeois'; - - $email = data_get($remoteIdentity, 'email') - ?: $remoteEmail - ?: $viewer?->email - ?: 'derek@katra.io'; - - $initials = collect(preg_split('/\s+/', trim($name)) ?: []) - ->filter() - ->take(2) - ->map(fn (string $segment): string => strtoupper(substr($segment, 0, 1))) - ->implode(''); - - return [ - 'name' => $name, - 'email' => $email, - 'initials' => $initials !== '' ? $initials : 'K', - ]; - } - - private function nameFromEmail(string $email): string - { - $localPart = (string) str($email)->before('@'); - $segments = preg_split('/[._-]+/', $localPart) ?: []; - $segments = array_values(array_filter(array_map( - fn (string $segment): string => str($segment)->title()->value(), - $segments, - ))); - - $firstName = $segments[0] ?? 'Remote'; - $lastName = count($segments) > 1 ? implode(' ', array_slice($segments, 1)) : 'User'; - - return trim($firstName.' '.$lastName); - } } diff --git a/app/Http/Requests/StoreWorkspaceChatMessageRequest.php b/app/Http/Requests/StoreWorkspaceChatMessageRequest.php new file mode 100644 index 0000000..f161165 --- /dev/null +++ b/app/Http/Requests/StoreWorkspaceChatMessageRequest.php @@ -0,0 +1,35 @@ +user() !== null; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'message_body' => ['required', 'string', 'max:10000', 'regex:/\\S/'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'message_body.required' => 'Enter a message before sending it.', + 'message_body.regex' => 'Messages cannot be blank.', + ]; + } +} diff --git a/app/Http/Requests/StoreWorkspaceChatRequest.php b/app/Http/Requests/StoreWorkspaceChatRequest.php new file mode 100644 index 0000000..26491ee --- /dev/null +++ b/app/Http/Requests/StoreWorkspaceChatRequest.php @@ -0,0 +1,38 @@ +user() !== null; + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'chat_name' => ['required', 'string', 'max:255', 'regex:/\\S/'], + 'chat_kind' => ['required', 'string', 'in:direct,group'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'chat_name.required' => 'Enter a chat name to create it in this workspace.', + 'chat_name.regex' => 'Chat names cannot be blank.', + 'chat_kind.required' => 'Choose whether this chat is direct or group.', + 'chat_kind.in' => 'Chats must be direct or group conversations.', + ]; + } +} diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 8e00675..ff9181d 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -7,8 +7,9 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; -#[Fillable(['instance_connection_id', 'name', 'slug', 'summary'])] +#[Fillable(['instance_connection_id', 'active_chat_id', 'name', 'slug', 'summary'])] class Workspace extends Model { /** @use HasFactory */ @@ -23,4 +24,20 @@ public function instanceConnection(): BelongsTo { return $this->belongsTo(InstanceConnection::class); } + + /** + * @return BelongsTo + */ + public function activeChat(): BelongsTo + { + return $this->belongsTo(WorkspaceChat::class, 'active_chat_id'); + } + + /** + * @return HasMany + */ + public function chats(): HasMany + { + return $this->hasMany(WorkspaceChat::class); + } } diff --git a/app/Models/WorkspaceChat.php b/app/Models/WorkspaceChat.php new file mode 100644 index 0000000..29c2dbc --- /dev/null +++ b/app/Models/WorkspaceChat.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return HasMany + */ + public function participants(): HasMany + { + return $this->hasMany(WorkspaceChatParticipant::class, 'chat_id'); + } + + /** + * @return HasMany + */ + public function messages(): HasMany + { + return $this->hasMany(WorkspaceChatMessage::class, 'chat_id'); + } +} diff --git a/app/Models/WorkspaceChatMessage.php b/app/Models/WorkspaceChatMessage.php new file mode 100644 index 0000000..0d75401 --- /dev/null +++ b/app/Models/WorkspaceChatMessage.php @@ -0,0 +1,30 @@ + */ + use HasFactory; + + /** + * @return BelongsTo + */ + public function chat(): BelongsTo + { + return $this->belongsTo(WorkspaceChat::class, 'chat_id'); + } +} diff --git a/app/Models/WorkspaceChatParticipant.php b/app/Models/WorkspaceChatParticipant.php new file mode 100644 index 0000000..c3a3222 --- /dev/null +++ b/app/Models/WorkspaceChatParticipant.php @@ -0,0 +1,36 @@ + */ + use HasFactory; + + /** + * @return BelongsTo + */ + public function chat(): BelongsTo + { + return $this->belongsTo(WorkspaceChat::class, 'chat_id'); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Support/Chats/WorkspaceChatManager.php b/app/Support/Chats/WorkspaceChatManager.php new file mode 100644 index 0000000..a275553 --- /dev/null +++ b/app/Support/Chats/WorkspaceChatManager.php @@ -0,0 +1,181 @@ + + */ + public function chatsFor(Workspace $workspace): Collection + { + return $workspace->chats() + ->orderByDesc('updated_at') + ->orderBy('name') + ->get() + ->values(); + } + + /** + * @param array{name: string, email: string, initials: string} $viewerIdentity + * @param Collection|null $chats + */ + public function activeChatFor( + Workspace $workspace, + User $viewer, + array $viewerIdentity, + ?Collection $chats = null, + ): WorkspaceChat { + $chats ??= $this->chatsFor($workspace); + $activeChat = $chats->firstWhere('id', $workspace->active_chat_id); + + if (! $activeChat instanceof WorkspaceChat) { + $activeChat = $chats->first() ?? $this->createChat($workspace, $viewer, $viewerIdentity, [ + 'name' => 'General chat', + 'kind' => WorkspaceChat::KIND_GROUP, + ]); + } + + if ((int) $workspace->active_chat_id !== (int) $activeChat->getKey()) { + $workspace->forceFill([ + 'active_chat_id' => $activeChat->getKey(), + ])->save(); + } + + return $activeChat; + } + + /** + * @param array{name: string, email: string, initials: string} $viewerIdentity + * @param array{name: string, kind: string} $attributes + */ + public function createChat( + Workspace $workspace, + User $viewer, + array $viewerIdentity, + array $attributes, + ): WorkspaceChat { + $chatName = trim($attributes['name']); + $chatKind = $attributes['kind'] === WorkspaceChat::KIND_DIRECT + ? WorkspaceChat::KIND_DIRECT + : WorkspaceChat::KIND_GROUP; + + $chat = $workspace->chats()->create([ + 'name' => $chatName, + 'slug' => $this->nextChatSlug($workspace, $chatName), + 'kind' => $chatKind, + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + 'summary' => $this->chatSummary($workspace, $chatName, $chatKind), + ]); + + $chat->participants()->create($this->defaultParticipant($viewer, $viewerIdentity)); + + $workspace->forceFill([ + 'active_chat_id' => $chat->getKey(), + ])->save(); + + return $chat; + } + + public function activateChat(WorkspaceChat $chat): void + { + $workspace = $chat->workspace; + + if ((int) $workspace->active_chat_id !== (int) $chat->getKey()) { + $workspace->forceFill([ + 'active_chat_id' => $chat->getKey(), + ])->save(); + } + } + + /** + * @param array{name: string, email: string, initials: string} $viewerIdentity + * @param array{body: string} $attributes + */ + public function createMessage( + WorkspaceChat $chat, + User $viewer, + array $viewerIdentity, + array $attributes, + ): WorkspaceChatMessage { + $message = $chat->messages()->create([ + 'sender_type' => WorkspaceChatMessage::SENDER_HUMAN, + 'sender_key' => $this->participantKey($viewer, $viewerIdentity), + 'sender_name' => $viewerIdentity['name'], + 'body' => trim($attributes['body']), + ]); + + $chat->touch(); + $this->activateChat($chat); + + return $message; + } + + /** + * @param array{name: string, email: string, initials: string} $viewerIdentity + * @return array + */ + private function defaultParticipant(User $viewer, array $viewerIdentity): array + { + return [ + 'user_id' => $viewer->getKey(), + 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, + 'participant_key' => $this->participantKey($viewer, $viewerIdentity), + 'display_name' => $viewerIdentity['name'], + ]; + } + + /** + * @param array{name: string, email: string, initials: string} $viewerIdentity + */ + private function participantKey(User $viewer, array $viewerIdentity): string + { + $email = trim((string) ($viewerIdentity['email'] ?? '')); + + if ($email !== '') { + return 'human:'.Str::lower($email); + } + + return 'human:user-'.$viewer->getKey(); + } + + private function nextChatSlug(Workspace $workspace, string $chatName): string + { + $baseSlug = Str::slug($chatName); + $baseSlug = $baseSlug !== '' ? $baseSlug : 'chat'; + $slug = $baseSlug; + $suffix = 2; + + while ($workspace->chats()->where('slug', $slug)->exists()) { + $slug = $baseSlug.'-'.$suffix; + $suffix++; + } + + return $slug; + } + + private function chatSummary(Workspace $workspace, string $chatName, string $chatKind): string + { + if ($chatKind === WorkspaceChat::KIND_DIRECT) { + return sprintf( + '%s is a private direct chat inside the %s workspace.', + $chatName, + $workspace->name, + ); + } + + return sprintf( + '%s is a private group chat inside the %s workspace.', + $chatName, + $workspace->name, + ); + } +} diff --git a/app/Support/Connections/ViewerIdentityResolver.php b/app/Support/Connections/ViewerIdentityResolver.php new file mode 100644 index 0000000..3e54355 --- /dev/null +++ b/app/Support/Connections/ViewerIdentityResolver.php @@ -0,0 +1,63 @@ +kind === InstanceConnection::KIND_SERVER + ? data_get($activeConnection->session_context, 'user') + : null; + $remoteEmail = $activeConnection->kind === InstanceConnection::KIND_SERVER + ? data_get($activeConnection->session_context, 'email') + : null; + $remoteName = data_get($remoteIdentity, 'name'); + + if ((! is_string($remoteName) || $remoteName === '') && is_string($remoteEmail) && $remoteEmail !== '') { + $remoteName = $this->nameFromEmail($remoteEmail); + } + + $name = $remoteName + ?: $viewer?->name + ?: 'Katra User'; + + $email = data_get($remoteIdentity, 'email') + ?: $remoteEmail + ?: $viewer?->email + ?: 'katra@example.test'; + + $initials = collect(preg_split('/\s+/', trim($name)) ?: []) + ->filter() + ->take(2) + ->map(fn (string $segment): string => strtoupper(substr($segment, 0, 1))) + ->implode(''); + + return [ + 'name' => $name, + 'email' => $email, + 'initials' => $initials !== '' ? $initials : 'K', + ]; + } + + private function nameFromEmail(string $email): string + { + $localPart = (string) str($email)->before('@'); + $segments = preg_split('/[._-]+/', $localPart) ?: []; + $segments = array_values(array_filter(array_map( + fn (string $segment): string => str($segment)->title()->value(), + $segments, + ))); + + $firstName = $segments[0] ?? 'Remote'; + $lastName = count($segments) > 1 ? implode(' ', array_slice($segments, 1)) : 'User'; + + return trim($firstName.' '.$lastName); + } +} diff --git a/database/factories/WorkspaceChatFactory.php b/database/factories/WorkspaceChatFactory.php new file mode 100644 index 0000000..0d2d1f8 --- /dev/null +++ b/database/factories/WorkspaceChatFactory.php @@ -0,0 +1,40 @@ + + */ +class WorkspaceChatFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->words(2, true); + + return [ + 'workspace_id' => Workspace::factory(), + 'name' => str($name)->title()->value(), + 'slug' => Str::slug($name), + 'kind' => WorkspaceChat::KIND_GROUP, + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + 'summary' => fake()->sentence(), + ]; + } + + public function direct(): static + { + return $this->state(fn (): array => [ + 'kind' => WorkspaceChat::KIND_DIRECT, + ]); + } +} diff --git a/database/factories/WorkspaceChatMessageFactory.php b/database/factories/WorkspaceChatMessageFactory.php new file mode 100644 index 0000000..8c7a0da --- /dev/null +++ b/database/factories/WorkspaceChatMessageFactory.php @@ -0,0 +1,29 @@ + + */ +class WorkspaceChatMessageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'chat_id' => WorkspaceChat::factory(), + 'sender_type' => WorkspaceChatMessage::SENDER_HUMAN, + 'sender_key' => 'human:'.fake()->uuid(), + 'sender_name' => fake()->name(), + 'body' => fake()->paragraph(), + ]; + } +} diff --git a/database/factories/WorkspaceChatParticipantFactory.php b/database/factories/WorkspaceChatParticipantFactory.php new file mode 100644 index 0000000..d59615d --- /dev/null +++ b/database/factories/WorkspaceChatParticipantFactory.php @@ -0,0 +1,41 @@ + + */ +class WorkspaceChatParticipantFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'chat_id' => WorkspaceChat::factory(), + 'user_id' => User::factory(), + 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, + 'participant_key' => 'human:'.fake()->unique()->safeEmail(), + 'display_name' => fake()->name(), + ]; + } + + public function agent(): static + { + return $this->state(fn (): array => [ + 'user_id' => null, + 'participant_type' => WorkspaceChatParticipant::TYPE_AGENT, + 'participant_key' => 'agent:'.Str::slug(fake()->unique()->words(2, true)), + 'display_name' => str(fake()->unique()->words(2, true))->title()->append(' Agent')->value(), + ]); + } +} diff --git a/database/migrations/2026_03_27_143418_create_workspace_chat_messages_table.php b/database/migrations/2026_03_27_143418_create_workspace_chat_messages_table.php new file mode 100644 index 0000000..c385455 --- /dev/null +++ b/database/migrations/2026_03_27_143418_create_workspace_chat_messages_table.php @@ -0,0 +1,38 @@ +getDriverName(); + + Schema::create('workspace_chat_messages', function (Blueprint $table) use ($driver): void { + $table->id(); + if ($driver === 'surreal') { + $table->unsignedBigInteger('chat_id'); + } else { + $table->foreignId('chat_id')->constrained('workspace_chats')->cascadeOnDelete(); + } + $table->string('sender_type', 25); + $table->string('sender_key')->nullable(); + $table->string('sender_name'); + $table->text('body'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_chat_messages'); + } +}; diff --git a/database/migrations/2026_03_27_143418_create_workspace_chat_participants_table.php b/database/migrations/2026_03_27_143418_create_workspace_chat_participants_table.php new file mode 100644 index 0000000..81fbd3a --- /dev/null +++ b/database/migrations/2026_03_27_143418_create_workspace_chat_participants_table.php @@ -0,0 +1,43 @@ +getDriverName(); + + Schema::create('workspace_chat_participants', function (Blueprint $table) use ($driver): void { + $table->id(); + if ($driver === 'surreal') { + $table->unsignedBigInteger('chat_id'); + $table->unsignedBigInteger('user_id')->nullable(); + } else { + $table->foreignId('chat_id')->constrained('workspace_chats')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + } + $table->string('participant_type', 25); + $table->string('participant_key'); + $table->string('display_name'); + $table->timestamps(); + + if ($driver !== 'surreal') { + $table->unique(['chat_id', 'participant_key'], 'workspace_chat_participants_chat_key_unique'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_chat_participants'); + } +}; diff --git a/database/migrations/2026_03_27_143418_create_workspace_chats_table.php b/database/migrations/2026_03_27_143418_create_workspace_chats_table.php new file mode 100644 index 0000000..9b30561 --- /dev/null +++ b/database/migrations/2026_03_27_143418_create_workspace_chats_table.php @@ -0,0 +1,43 @@ +getDriverName(); + + Schema::create('workspace_chats', function (Blueprint $table) use ($driver): void { + $table->id(); + if ($driver === 'surreal') { + $table->unsignedBigInteger('workspace_id'); + } else { + $table->foreignId('workspace_id')->constrained('connection_workspaces')->cascadeOnDelete(); + } + $table->string('name'); + $table->string('slug'); + $table->string('kind', 25); + $table->string('visibility', 25); + $table->text('summary')->nullable(); + $table->timestamps(); + + if ($driver !== 'surreal') { + $table->unique(['workspace_id', 'slug'], 'workspace_chats_workspace_slug_unique'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workspace_chats'); + } +}; diff --git a/database/migrations/2026_03_27_143427_add_active_chat_id_to_connection_workspaces_table.php b/database/migrations/2026_03_27_143427_add_active_chat_id_to_connection_workspaces_table.php new file mode 100644 index 0000000..57392fd --- /dev/null +++ b/database/migrations/2026_03_27_143427_add_active_chat_id_to_connection_workspaces_table.php @@ -0,0 +1,44 @@ +getDriverName(); + + Schema::table('connection_workspaces', function (Blueprint $table) use ($driver): void { + if ($driver === 'surreal') { + $table->unsignedBigInteger('active_chat_id')->nullable()->after('summary'); + } else { + $table->foreignId('active_chat_id') + ->nullable() + ->after('summary') + ->constrained('workspace_chats') + ->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $driver = Schema::getConnection()->getDriverName(); + + Schema::table('connection_workspaces', function (Blueprint $table) use ($driver): void { + if ($driver !== 'surreal') { + $table->dropForeign(['active_chat_id']); + } + + $table->dropColumn('active_chat_id'); + }); + } +}; diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 06b7308..f58ef8f 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -34,11 +34,17 @@ ['title' => 'General', 'meta' => 'Workspace', 'summary' => 'Default workspace for the current connection.'], ], ], + [ + 'label' => 'Chats', + 'items' => [ + ['title' => $activeChat->name, 'meta' => 'Private chat', 'summary' => $activeChat->summary ?: 'The active private conversation for this workspace.'], + ], + ], [ 'label' => 'People and agents', 'items' => [ ['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.'], + ['title' => $participants[0]['label'] ?? $viewerName, 'meta' => $participants[0]['meta'] ?? 'Human', 'summary' => 'Participant in the active private chat.'], ], ], [ @@ -50,35 +56,6 @@ ], ]; - $conversationSeedMessages = collect($messages) - ->values() - ->map(fn (array $message, int $index): array => [ - 'id' => 'seed-'.$index, - 'sender' => $message['speaker'], - 'role' => $message['role'], - 'meta' => $message['meta'], - 'body' => $message['body'], - 'direction' => $message['speaker'] === 'You' ? 'outgoing' : 'incoming', - 'attachments' => [], - ]) - ->all(); - - $conversationResponders = collect($participants) - ->filter(fn (array $participant): bool => $participant['meta'] !== 'Human') - ->values() - ->map(fn (array $participant): array => [ - 'label' => $participant['label'], - 'role' => $participant['meta'], - ]) - ->all(); - - $conversationMockReplies = [ - 'I can break this into a couple of linked nodes without changing the flow of the room.', - 'That looks good. I would tighten the interaction first, then let the linked work follow behind it.', - 'We can keep the room focused and still attach the next task, artifact, or decision from the context rail.', - 'This conversation feels clearer when the structure stays quiet and the actions stay close to the composer.', - 'I can take the next pass on this and keep the changes scoped to the active conversation.', - ]; @endphp
@@ -131,7 +108,14 @@ class="shell-icon-button inline-flex h-7 w-7 items-center justify-center rounded @foreach ($chatLinks as $item) - + @endforeach
@@ -238,6 +222,10 @@ class="shell-icon-button inline-flex h-8 w-8 items-center justify-center rounded

{{ $activeWorkspace['summary'] }}

+
+ Private chat + {{ $activeChat->name }} +
@@ -248,9 +236,9 @@ class="shell-icon-button inline-flex h-8 w-8 items-center justify-center rounded data-conversation-stream class="min-h-0 flex-1 space-y-4 overflow-y-auto px-1 pb-4" > - @foreach ($conversationSeedMessages as $message) + @forelse ($messages as $message) @php - $isOutgoing = $message['direction'] === 'outgoing'; + $isOutgoing = $message['role'] === 'Human' && $message['speaker'] === $viewerName; $messageRoleTone = match ($message['role']) { 'Human' => 'shell-text-faint', 'Agent' => 'text-[color:var(--shell-accent)]', @@ -262,7 +250,7 @@ class="min-h-0 flex-1 space-y-4 overflow-y-auto px-1 pb-4"
- {{ $message['sender'] }} + {{ $message['speaker'] }} {{ $message['role'] }} {{ $message['meta'] }}
@@ -272,61 +260,33 @@ class="min-h-0 flex-1 space-y-4 overflow-y-auto px-1 pb-4"
- @endforeach + @empty +
+

{{ $activeChat->name }}

+

No messages yet. Start the conversation from this private chat and it will stay scoped to the {{ $activeWorkspace['label'] }} workspace.

+
+ @endforelse -
- - - +
+ @csrf - -
-
- - - -
+ placeholder="Message {{ $activeChat->name }}" + aria-label="Message {{ $activeChat->name }}" + >{{ old('message_body') }} + + @error('message_body') +

{{ $message }}

+ @enderror +
-
+
@@ -806,62 +766,48 @@ class="shell-input shell-text w-full rounded-[18px] px-4 py-3 text-sm outline-no - -
-
-
- - -
+ + + @csrf -
-

Selected

-
-

No contacts selected yet.

-
-
+
+ + + @error('chat_name') +

{{ $message }}

+ @enderror +
-
-

Available contacts

-
- @foreach ($chatContacts as $contact) - - @endforeach -
-
+
+ + + @error('chat_kind') +

{{ $message }}

+ @enderror
+

Chats stay private to this workspace and are kept out of the shared workspace graph.

+
@@ -885,12 +831,6 @@ class="shell-input flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text- const searchBackdrop = shell.querySelector('[data-search-backdrop]'); const conversationStream = shell.querySelector('[data-conversation-stream]'); const messageInput = shell.querySelector('[data-message-input]'); - const sendMessageButton = shell.querySelector('[data-send-message]'); - const attachFileButton = shell.querySelector('[data-attach-file]'); - const messageFileInput = shell.querySelector('[data-message-file-input]'); - const messageAttachments = shell.querySelector('[data-message-attachments]'); - const voiceToggleButton = shell.querySelector('[data-voice-toggle]'); - const voiceIndicator = shell.querySelector('[data-voice-indicator]'); const profileMenu = shell.querySelector('[data-profile-menu]'); const themeButtons = shell.querySelectorAll('[data-theme-option]'); const collapseButtons = shell.querySelectorAll('[data-sidebar-toggle]'); @@ -903,31 +843,22 @@ class="shell-input flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text- const dialogButtons = document.querySelectorAll('[data-dialog-target]'); const dialogCloseButtons = document.querySelectorAll('[data-dialog-close]'); const navSectionButtons = shell.querySelectorAll('[data-nav-section-toggle]'); - const contactSelector = document.querySelector('[data-contact-selector]'); const storageKey = 'katra.desktop.sidebar.preference'; const rightRailStorageKey = 'katra.desktop.right-rail.preference'; const rightRailPinStorageKey = 'katra.desktop.right-rail.pin'; const rightRailWidthStorageKey = 'katra.desktop.right-rail.width'; - const conversationStorageKey = @json('katra.desktop.conversation.' . $activeWorkspace['slug']); const themeStorageKey = 'katra.desktop.theme.preference'; const autoCollapseWidth = 1480; const rightRailAutoCollapseWidth = 1280; const rightRailMinWidth = 280; const rightRailMaxWidth = 560; const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const conversationSeedMessages = @json($conversationSeedMessages ?? []); - const conversationResponders = @json($conversationResponders ?? []); - const conversationMockReplies = @json($conversationMockReplies ?? []); let sidebarPreference = window.localStorage.getItem(storageKey); let rightRailPreference = window.localStorage.getItem(rightRailStorageKey); let rightRailPinned = window.localStorage.getItem(rightRailPinStorageKey) === 'true'; let rightRailWidth = Number.parseInt(window.localStorage.getItem(rightRailWidthStorageKey) ?? '320', 10); let themePreference = window.localStorage.getItem(themeStorageKey) ?? 'system'; - let conversationMessages = []; - let pendingAttachments = []; - let voiceModeEnabled = false; - let pendingResponseTimer = null; if (Number.isNaN(rightRailWidth)) { rightRailWidth = 320; @@ -951,140 +882,6 @@ class="shell-input flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text- }); }; - const conversationTimeFormatter = new Intl.DateTimeFormat([], { - hour: 'numeric', - minute: '2-digit', - }); - - const escapeHtml = (value) => String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - - const conversationStorageAvailable = () => typeof window.sessionStorage !== 'undefined'; - - const readConversationState = () => { - if (! conversationStorageAvailable()) { - return [...conversationSeedMessages]; - } - - const stored = window.sessionStorage.getItem(conversationStorageKey); - - if (! stored) { - return [...conversationSeedMessages]; - } - - try { - const decoded = JSON.parse(stored); - - return Array.isArray(decoded) ? decoded : [...conversationSeedMessages]; - } catch (error) { - return [...conversationSeedMessages]; - } - }; - - const persistConversationState = () => { - if (! conversationStorageAvailable()) { - return; - } - - window.sessionStorage.setItem(conversationStorageKey, JSON.stringify(conversationMessages)); - }; - - const timestampLabel = () => conversationTimeFormatter.format(new Date()); - - const participantTone = (role) => { - if (role === 'Human') { - return 'shell-human-chip'; - } - - return 'shell-bot-chip'; - }; - - const participantPrefix = (sender, role) => { - if (role === 'Human') { - return sender.slice(0, 1).toUpperCase(); - } - - return '@'; - }; - - const roleTone = (role) => { - if (role === 'Agent') { - return 'text-[color:var(--shell-accent)]'; - } - - if (role === 'Model' || role === 'Worker') { - return 'shell-text-info-strong'; - } - - return 'shell-text-faint'; - }; - - const attachmentMarkup = (attachments = []) => { - if (! Array.isArray(attachments) || attachments.length === 0) { - return ''; - } - - return ` -
- ${attachments.map((attachment) => ` - - ${escapeHtml(attachment.name)} - Mock - - `).join('')} -
- `; - }; - - const buildConversationMessage = (message) => { - const outgoing = message.direction === 'outgoing'; - const bubbleTone = outgoing ? 'shell-accent-soft' : 'shell-elevated'; - const align = outgoing ? 'justify-end' : 'justify-start'; - const itemAlign = outgoing ? 'items-end' : 'items-start'; - const avatarTone = participantTone(message.role); - const statusTone = roleTone(message.role); - const bodyMarkup = message.typing - ? ` -
- - - - - - Thinking -
- ` - : message.body - ? `

${escapeHtml(message.body)}

` - : ''; - - return ` -
-
-
- ${outgoing ? '' : ` - - ${escapeHtml(participantPrefix(message.sender, message.role))} - - `} - ${escapeHtml(message.sender)} - ${escapeHtml(message.role)} - ${escapeHtml(message.meta ?? '')} -
- -
- ${bodyMarkup} - ${attachmentMarkup(message.attachments)} -
-
-
- `; - }; - const scrollConversationToBottom = () => { if (! conversationStream) { return; @@ -1095,15 +892,6 @@ class="shell-input flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text- }); }; - const renderConversation = () => { - if (! conversationStream) { - return; - } - - conversationStream.innerHTML = conversationMessages.map(buildConversationMessage).join(''); - scrollConversationToBottom(); - }; - const syncComposerHeight = () => { if (! messageInput) { return; @@ -1113,129 +901,6 @@ class="shell-input flex w-full items-center gap-3 rounded-[18px] px-3 py-3 text- messageInput.style.height = `${Math.min(messageInput.scrollHeight, 160)}px`; }; - const renderPendingAttachments = () => { - if (! messageAttachments) { - return; - } - - if (pendingAttachments.length === 0) { - messageAttachments.classList.add('hidden'); - messageAttachments.innerHTML = ''; - - return; - } - - messageAttachments.classList.remove('hidden'); - messageAttachments.innerHTML = pendingAttachments.map((attachment) => ` - - `).join(''); - }; - - const applyVoiceState = () => { - if (! voiceToggleButton) { - return; - } - - voiceToggleButton.classList.toggle('shell-accent-chip', voiceModeEnabled); - voiceToggleButton.classList.toggle('shell-elevated', ! voiceModeEnabled); - voiceToggleButton.setAttribute('aria-pressed', voiceModeEnabled ? 'true' : 'false'); - - if (voiceIndicator) { - voiceIndicator.classList.toggle('hidden', ! voiceModeEnabled); - voiceIndicator.classList.toggle('flex', voiceModeEnabled); - } - }; - - const nextAgentReply = () => { - const responder = conversationResponders[Math.floor(Math.random() * conversationResponders.length)] ?? { - label: 'Katra Agent', - role: 'Agent', - }; - const body = conversationMockReplies[Math.floor(Math.random() * conversationMockReplies.length)]; - - return { - id: `reply-${Date.now()}`, - sender: responder.label, - role: responder.role === 'Worker' ? 'Agent' : responder.role, - meta: timestampLabel(), - body, - direction: 'incoming', - attachments: [], - }; - }; - - const queueMockResponse = () => { - if (pendingResponseTimer) { - window.clearTimeout(pendingResponseTimer); - } - - const typingMessage = { - id: `typing-${Date.now()}`, - sender: conversationResponders[0]?.label ?? 'Katra Agent', - role: 'Agent', - meta: 'Thinking', - body: '', - direction: 'incoming', - attachments: [], - typing: true, - }; - - conversationMessages.push(typingMessage); - renderConversation(); - persistConversationState(); - - pendingResponseTimer = window.setTimeout(() => { - conversationMessages = conversationMessages.filter((message) => ! message.typing); - conversationMessages.push(nextAgentReply()); - renderConversation(); - persistConversationState(); - }, 850); - }; - - const submitConversationMessage = () => { - if (! messageInput) { - return; - } - - const body = messageInput.value.trim(); - - if (body === '' && pendingAttachments.length === 0 && ! voiceModeEnabled) { - return; - } - - const attachments = pendingAttachments.map((attachment) => ({ - name: attachment.name, - })); - - const outgoingMessage = { - id: `message-${Date.now()}`, - sender: 'You', - role: 'Human', - meta: timestampLabel(), - body: body === '' && voiceModeEnabled ? 'Voice mode selected for this reply.' : body, - direction: 'outgoing', - attachments, - }; - - conversationMessages.push(outgoingMessage); - messageInput.value = ''; - pendingAttachments = []; - voiceModeEnabled = false; - renderPendingAttachments(); - applyVoiceState(); - syncComposerHeight(); - renderConversation(); - persistConversationState(); - queueMockResponse(); - }; - const openSearchOverlay = () => { searchOverlay?.classList.remove('hidden'); searchBackdrop?.classList.remove('hidden'); @@ -1581,97 +1246,6 @@ class="shell-elevated inline-flex items-center gap-2 rounded-full px-3 py-2 text }); }); - if (contactSelector) { - const searchInput = contactSelector.querySelector('[data-contact-search]'); - const selectedContainer = contactSelector.querySelector('[data-contact-selected]'); - const emptyState = contactSelector.querySelector('[data-contact-empty]'); - const optionButtons = Array.from(contactSelector.querySelectorAll('[data-contact-option]')); - const selectedContacts = new Map(); - - const renderSelectedContacts = () => { - if (! selectedContainer) { - return; - } - - selectedContainer.querySelectorAll('[data-contact-chip]').forEach((chip) => chip.remove()); - - if (selectedContacts.size === 0) { - emptyState?.classList.remove('hidden'); - - return; - } - - emptyState?.classList.add('hidden'); - - selectedContacts.forEach((contact) => { - const chip = document.createElement('button'); - chip.type = 'button'; - chip.dataset.contactChip = contact.value; - chip.className = 'shell-accent-soft inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium'; - chip.innerHTML = ` - ${contact.label} - - `; - - chip.addEventListener('click', () => { - selectedContacts.delete(contact.value); - renderSelectedContacts(); - renderContactOptions(searchInput?.value ?? ''); - }); - - selectedContainer.appendChild(chip); - }); - }; - - const renderContactOptions = (query = '') => { - const normalizedQuery = query.trim().toLowerCase(); - - optionButtons.forEach((button) => { - const value = button.dataset.contactValue ?? ''; - const label = (button.dataset.contactLabel ?? '').toLowerCase(); - const matchesQuery = normalizedQuery === '' || label.includes(normalizedQuery); - const selected = selectedContacts.has(value); - - button.classList.toggle('hidden', ! matchesQuery); - button.classList.toggle('opacity-60', selected); - button.setAttribute('aria-pressed', selected ? 'true' : 'false'); - - const action = button.querySelector('.shell-text-info-strong'); - - if (action) { - action.textContent = selected ? 'Selected' : 'Add'; - } - }); - }; - - optionButtons.forEach((button) => { - button.addEventListener('click', () => { - const value = button.dataset.contactValue ?? ''; - const label = button.dataset.contactLabel ?? ''; - - if (! value || ! label) { - return; - } - - if (selectedContacts.has(value)) { - selectedContacts.delete(value); - } else { - selectedContacts.set(value, { value, label }); - } - - renderSelectedContacts(); - renderContactOptions(searchInput?.value ?? ''); - }); - }); - - searchInput?.addEventListener('input', (event) => { - renderContactOptions(event.target.value ?? ''); - }); - - renderSelectedContacts(); - renderContactOptions(); - } - shell.querySelectorAll('[data-node-tabs]').forEach((nodeTabs) => { const buttons = Array.from(nodeTabs.querySelectorAll('[data-node-tab-button]')); const panels = Array.from(nodeTabs.querySelectorAll('[data-node-tab-panel]')); @@ -1709,75 +1283,13 @@ class="shell-elevated inline-flex items-center gap-2 rounded-full px-3 py-2 text }); }); - conversationMessages = readConversationState(); - persistConversationState(); - renderConversation(); - renderPendingAttachments(); - applyVoiceState(); + scrollConversationToBottom(); syncComposerHeight(); messageInput?.addEventListener('input', () => { syncComposerHeight(); }); - messageInput?.addEventListener('keydown', (event) => { - if (event.key === 'Enter' && ! event.shiftKey) { - event.preventDefault(); - submitConversationMessage(); - } - }); - - sendMessageButton?.addEventListener('click', () => { - submitConversationMessage(); - }); - - attachFileButton?.addEventListener('click', () => { - messageFileInput?.click(); - }); - - messageFileInput?.addEventListener('change', (event) => { - const target = event.target; - - if (! (target instanceof HTMLInputElement) || ! target.files) { - return; - } - - pendingAttachments = [ - ...pendingAttachments, - ...Array.from(target.files).map((file, index) => ({ - id: `${Date.now()}-${index}-${file.name}`, - name: file.name, - })), - ]; - - renderPendingAttachments(); - target.value = ''; - }); - - messageAttachments?.addEventListener('click', (event) => { - const target = event.target; - - if (! (target instanceof HTMLElement)) { - return; - } - - const removeButton = target.closest('[data-remove-attachment]'); - - if (! removeButton) { - return; - } - - const attachmentId = removeButton.getAttribute('data-remove-attachment'); - - pendingAttachments = pendingAttachments.filter((attachment) => attachment.id !== attachmentId); - renderPendingAttachments(); - }); - - voiceToggleButton?.addEventListener('click', () => { - voiceModeEnabled = ! voiceModeEnabled; - applyVoiceState(); - }); - themeButtons.forEach((button) => { button.addEventListener('click', () => { themePreference = button.dataset.themeOption ?? 'system'; diff --git a/routes/web.php b/routes/web.php index c68d157..0e09dd3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('connections.authenticate'); Route::post('/workspaces', [WorkspaceController::class, 'store'])->name('workspaces.store'); Route::post('/workspaces/{workspace}/activate', [WorkspaceController::class, 'activate'])->name('workspaces.activate'); + Route::post('/chats', [ChatController::class, 'store'])->name('chats.store'); + Route::post('/chats/{workspaceChat}/activate', [ChatController::class, 'activate'])->name('chats.activate'); + Route::post('/chats/{workspaceChat}/messages', [ChatMessageController::class, 'store'])->name('chats.messages.store'); }); diff --git a/tests/Feature/DesktopShellTest.php b/tests/Feature/DesktopShellTest.php index fb2aaf4..c510b84 100644 --- a/tests/Feature/DesktopShellTest.php +++ b/tests/Feature/DesktopShellTest.php @@ -3,6 +3,9 @@ use App\Models\InstanceConnection; use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceChat; +use App\Models\WorkspaceChatMessage; +use App\Models\WorkspaceChatParticipant; use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs; @@ -50,10 +53,48 @@ function desktopShellUser(): User 'name' => 'General', 'slug' => 'general', ]); + $activeChat = WorkspaceChat::factory()->for($activeWorkspace, 'workspace')->create([ + 'name' => 'Design Review', + 'slug' => 'design-review', + 'kind' => WorkspaceChat::KIND_GROUP, + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + ]); + WorkspaceChat::factory()->for($activeWorkspace, 'workspace')->direct()->create([ + 'name' => 'Founder Sync', + 'slug' => 'founder-sync', + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + ]); + WorkspaceChatParticipant::factory()->for($activeChat, 'chat')->create([ + 'user_id' => $user->getKey(), + 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, + 'participant_key' => 'human:derek@katra.io', + 'display_name' => 'Derek Bourgeois', + ]); + WorkspaceChatParticipant::factory()->for($activeChat, 'chat')->create([ + 'user_id' => null, + 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, + 'participant_key' => 'human:morgan@katra.io', + 'display_name' => 'Morgan Hale', + ]); + WorkspaceChatMessage::factory()->for($activeChat, 'chat')->create([ + 'sender_type' => WorkspaceChatMessage::SENDER_HUMAN, + 'sender_key' => 'human:derek@katra.io', + 'sender_name' => 'Derek Bourgeois', + 'body' => 'Lock the private chat layer before we wire agents into it.', + ]); + WorkspaceChatMessage::factory()->for($activeChat, 'chat')->create([ + 'sender_type' => WorkspaceChatMessage::SENDER_HUMAN, + 'sender_key' => 'human:morgan@katra.io', + 'sender_name' => 'Morgan Hale', + 'body' => 'Agreed. The workspace should own chats, not the other way around.', + ]); $currentConnection->forceFill([ 'active_workspace_id' => $activeWorkspace->getKey(), ])->save(); + $activeWorkspace->forceFill([ + 'active_chat_id' => $activeChat->getKey(), + ])->save(); InstanceConnection::factory()->for($user)->create([ 'name' => 'Relay Cloud', @@ -76,8 +117,9 @@ function desktopShellUser(): User ->assertSee('Create chat') ->assertSee('Product Atlas') ->assertSee('General') - ->assertSee('Planner Agent') - ->assertSee('Research Model') + ->assertSee('Design Review') + ->assertSee('Founder Sync') + ->assertSee('Morgan Hale') ->assertSee('# general') ->assertSee('Connections') ->assertSee('Add a server') @@ -101,15 +143,10 @@ function desktopShellUser(): User ->assertSee('Open') ->assertSee('Closed') ->assertSee('In review') - ->assertSee('Assign to agent') - ->assertSee('Assign') - ->assertSee('Choose an agent') - ->assertSee('Context Agent') - ->assertSee('Attach file') - ->assertSee('Toggle voice mode') ->assertSee('Send message') - ->assertSee('Message Product Atlas') - ->assertSee('Voice mode selected') + ->assertSee('Message Design Review') + ->assertSee('Lock the private chat layer before we wire agents into it.') + ->assertSee('Agreed. The workspace should own chats, not the other way around.') ->assertSee('Product Atlas is a workspace on this instance for conversations, tasks, and linked work.') ->assertSee('Derek Bourgeois') ->assertSee('derek@katra.io') @@ -181,10 +218,31 @@ function desktopShellUser(): User 'slug' => 'relay-launch', 'summary' => 'Relay Launch is the active workspace on Relay Cloud for shared orchestration, worker presence, and linked team context.', ]); + $activeChat = WorkspaceChat::factory()->for($workspace, 'workspace')->create([ + 'name' => 'Ops Briefing', + 'slug' => 'ops-briefing', + 'kind' => WorkspaceChat::KIND_GROUP, + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + ]); + WorkspaceChatParticipant::factory()->for($activeChat, 'chat')->create([ + 'user_id' => null, + 'participant_type' => WorkspaceChatParticipant::TYPE_HUMAN, + 'participant_key' => 'human:ops@relay.devoption.test', + 'display_name' => 'Relay Operator', + ]); + WorkspaceChatMessage::factory()->for($activeChat, 'chat')->create([ + 'sender_type' => WorkspaceChatMessage::SENDER_HUMAN, + 'sender_key' => 'human:ops@relay.devoption.test', + 'sender_name' => 'Relay Operator', + 'body' => 'Remote conversations stay private to this workspace and do not enter the shared graph.', + ]); $connection->forceFill([ 'active_workspace_id' => $workspace->getKey(), ])->save(); + $workspace->forceFill([ + 'active_chat_id' => $activeChat->getKey(), + ])->save(); actingAs($user) ->withSession(['instance_connection.active_id' => $connection->getKey()]); @@ -194,10 +252,10 @@ function desktopShellUser(): User ->assertSee('Relay Cloud') ->assertSee('Connections') ->assertSee('Relay Launch') + ->assertSee('Ops Briefing') ->assertSee('# general') - ->assertSee('Ops Agent') - ->assertSee('Routing Agent') ->assertSee('Relay Operator') ->assertSee('ops@relay.devoption.test') + ->assertSee('Remote conversations stay private to this workspace and do not enter the shared graph.') ->assertSee('Signed in as ops@relay.devoption.test.'); }); diff --git a/tests/Feature/InstanceConnectionManagementTest.php b/tests/Feature/InstanceConnectionManagementTest.php index a6fc099..9a2fe9f 100644 --- a/tests/Feature/InstanceConnectionManagementTest.php +++ b/tests/Feature/InstanceConnectionManagementTest.php @@ -3,6 +3,9 @@ use App\Models\InstanceConnection; use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceChat; +use App\Models\WorkspaceChatMessage; +use App\Models\WorkspaceChatParticipant; use App\Support\Connections\InstanceConnectionManager; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Client\Request as HttpRequest; @@ -253,10 +256,31 @@ 'name' => 'Relay Launch', 'slug' => 'relay-launch', ]); + $chat = WorkspaceChat::factory()->for($workspace, 'workspace')->create([ + 'name' => 'Ops Briefing', + 'slug' => 'ops-briefing', + 'kind' => WorkspaceChat::KIND_GROUP, + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + ]); + WorkspaceChatParticipant::factory()->for($chat, 'chat')->create([ + 'user_id' => null, + 'participant_type' => WorkspaceChatParticipant::TYPE_AGENT, + 'participant_key' => 'agent:ops-agent', + 'display_name' => 'Ops Agent', + ]); + WorkspaceChatMessage::factory()->for($chat, 'chat')->create([ + 'sender_type' => WorkspaceChatMessage::SENDER_AGENT, + 'sender_key' => 'agent:ops-agent', + 'sender_name' => 'Ops Agent', + 'body' => 'Relay Cloud keeps private chats scoped to the active workspace.', + ]); $connection->forceFill([ 'active_workspace_id' => $workspace->getKey(), ])->save(); + $workspace->forceFill([ + 'active_chat_id' => $chat->getKey(), + ])->save(); actingAs($user)->withSession([ 'instance_connection.active_id' => $connection->getKey(), @@ -267,7 +291,9 @@ ->assertSee('Relay Cloud') ->assertSee('Relay Launch') ->assertSee('# general') + ->assertSee('Ops Briefing') ->assertSee('Ops Agent') + ->assertSee('Relay Cloud keeps private chats scoped to the active workspace.') ->assertSee('Relay Ops') ->assertSee('ops@relay.devoption.test'); }); diff --git a/tests/Feature/WorkspaceChatManagementTest.php b/tests/Feature/WorkspaceChatManagementTest.php new file mode 100644 index 0000000..2d943cb --- /dev/null +++ b/tests/Feature/WorkspaceChatManagementTest.php @@ -0,0 +1,133 @@ +create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create(); + $workspace = Workspace::factory()->for($connection)->create([ + 'active_chat_id' => null, + ]); + + $chat = app(WorkspaceChatManager::class)->activeChatFor($workspace, $user, [ + 'name' => $user->name, + 'email' => $user->email, + 'initials' => 'KU', + ]); + + expect($chat->name)->toBe('General chat') + ->and($chat->kind)->toBe(WorkspaceChat::KIND_GROUP) + ->and($chat->visibility)->toBe(WorkspaceChat::VISIBILITY_PRIVATE) + ->and($workspace->fresh()->active_chat_id)->toBe($chat->getKey()) + ->and($chat->participants()->count())->toBe(1); +}); + +it('creates private workspace chats from the shell', function (string $kind) { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create([ + 'base_url' => 'https://katra.test', + ]); + $workspace = Workspace::factory()->for($connection)->create(); + + $workspace->forceFill([ + 'active_chat_id' => null, + ])->save(); + + actingAs($user)->withSession([ + 'instance_connection.active_id' => $connection->getKey(), + ]); + + post(route('chats.store'), [ + 'chat_name' => $kind === WorkspaceChat::KIND_DIRECT ? 'Morgan' : 'Design Review', + 'chat_kind' => $kind, + ])->assertRedirect(route('home')); + + $chat = $workspace->fresh()->chats()->latest('id')->first(); + + expect($chat)->not()->toBeNull() + ->and($chat?->kind)->toBe($kind) + ->and($chat?->visibility)->toBe(WorkspaceChat::VISIBILITY_PRIVATE) + ->and($workspace->fresh()->active_chat_id)->toBe($chat?->getKey()) + ->and($chat?->participants()->count())->toBe(1); +})->with([ + WorkspaceChat::KIND_DIRECT, + WorkspaceChat::KIND_GROUP, +]); + +test('an authenticated user can activate another chat within the same workspace', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create(); + $workspace = Workspace::factory()->for($connection)->create(); + $firstChat = WorkspaceChat::factory()->for($workspace, 'workspace')->create(); + $secondChat = WorkspaceChat::factory()->for($workspace, 'workspace')->direct()->create(); + + $workspace->forceFill([ + 'active_chat_id' => $firstChat->getKey(), + ])->save(); + + actingAs($user); + + post(route('chats.activate', $secondChat)) + ->assertRedirect(route('home')); + + expect($workspace->fresh()->active_chat_id)->toBe($secondChat->getKey()); +}); + +test('an authenticated user can post a durable message into a workspace chat', function () { + $user = User::factory()->create([ + 'first_name' => 'Derek', + 'last_name' => 'Bourgeois', + 'name' => 'Derek Bourgeois', + 'email' => 'derek@katra.io', + ]); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create(); + $workspace = Workspace::factory()->for($connection)->create(); + $chat = WorkspaceChat::factory()->for($workspace, 'workspace')->create([ + 'name' => 'Design Review', + ]); + + actingAs($user); + + post(route('chats.messages.store', $chat), [ + 'message_body' => 'Lock the private chat layer before we wire agents into it.', + ])->assertRedirect(route('home')); + + $message = $chat->fresh()->messages()->first(); + + expect($message)->not()->toBeNull() + ->and($message?->sender_type)->toBe(WorkspaceChatMessage::SENDER_HUMAN) + ->and($message?->sender_name)->toBe('Derek Bourgeois') + ->and($message?->body)->toBe('Lock the private chat layer before we wire agents into it.') + ->and($workspace->fresh()->active_chat_id)->toBe($chat->getKey()); +}); + +test('workspace chats stay private to their parent workspace', function () { + $user = User::factory()->create(); + $connection = InstanceConnection::factory()->for($user)->currentInstance()->create(); + $workspace = Workspace::factory()->for($connection)->create(); + $otherWorkspace = Workspace::factory()->for($connection)->create(); + $chat = WorkspaceChat::factory()->for($workspace, 'workspace')->create([ + 'visibility' => WorkspaceChat::VISIBILITY_PRIVATE, + ]); + + WorkspaceChatParticipant::factory()->for($chat, 'chat')->create([ + 'participant_key' => 'human:derek@katra.io', + 'display_name' => 'Derek Bourgeois', + ]); + + expect($workspace->chats()->count())->toBe(1) + ->and($otherWorkspace->chats()->count())->toBe(0) + ->and($chat->visibility)->toBe(WorkspaceChat::VISIBILITY_PRIVATE); +});