From 2c08f6b4240498c93e4b57970277009ff9cef1b1 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 7 Aug 2026 15:04:08 -0700 Subject: [PATCH 1/2] RG-T117 Changes to Chatbot and Chat UI --- Core/Resgrid.Localization/Common.ar.resx | 4 +- Core/Resgrid.Localization/Common.de.resx | 2 +- Core/Resgrid.Localization/Common.es.resx | 4 +- Core/Resgrid.Localization/Common.fr.resx | 2 +- Core/Resgrid.Localization/Common.it.resx | 2 +- Core/Resgrid.Localization/Common.pl.resx | 4 +- Core/Resgrid.Localization/Common.sv.resx | 4 +- Core/Resgrid.Localization/Common.uk.resx | 4 +- Core/Resgrid.Model/Services/IChatServices.cs | 3 + Core/Resgrid.Services/ChatChannelService.cs | 28 ++- .../Resgrid.Services/ChatPermissionService.cs | 2 +- .../Services/ChatChannelServiceTests.cs | 58 ++++++ .../Resgrid.Web.Eventing.csproj | 2 + Web/Resgrid.Web.Eventing/Startup.cs | 4 + .../Health/TtsDependencyHealthCheck.cs | 21 +++ .../components/chat/AssistantPanelElement.tsx | 72 ++++++++ .../Apps/src/components/chat/ChannelList.tsx | 19 +- .../src/components/chat/ChatPageElement.tsx | 12 +- .../src/components/chat/ChatPanelElement.tsx | 36 +++- .../User/Apps/src/components/chat/chat.css | 174 ++++++++++-------- .../Apps/src/components/chat/chatStore.ts | 10 +- .../src/components/chat/useChatBootstrap.ts | 20 +- .../Areas/User/Apps/src/elements.ts | 12 +- .../Areas/User/Controllers/ChatController.cs | 26 ++- .../User/Controllers/ModerationController.cs | 7 +- .../Areas/User/Views/Chat/Chatbot.cshtml | 34 ---- .../Areas/User/Views/Shared/_Footer.cshtml | 5 +- .../User/Views/Shared/_Navigation.cshtml | 24 ++- .../Areas/User/Views/Shared/_TopNavbar.cshtml | 16 +- .../User/Views/Shared/_UserLayout.cshtml | 2 - 30 files changed, 445 insertions(+), 168 deletions(-) create mode 100644 Web/Resgrid.Web/Areas/User/Apps/src/components/chat/AssistantPanelElement.tsx delete mode 100644 Web/Resgrid.Web/Areas/User/Views/Chat/Chatbot.cshtml diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index dd4dc2892..f9fe0a171 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -36,10 +36,10 @@ اتصال جهات الاتصال - Chat + الدردشة - Assistant + المساعد مراجعة المحتوى diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index 5a77731e7..f1256ad63 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -162,7 +162,7 @@ Chat - Assistant + Assistent Moderation diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index cbcd2dd4e..fb6971c67 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -181,7 +181,7 @@ Certificaciones - Cerca + Cerrar Cerrado @@ -208,7 +208,7 @@ Chat - Assistant + Asistente Moderación diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index bdac4533f..33142dd61 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -159,7 +159,7 @@ Contacts - Chat + Discussion Assistant diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 0e586af2c..80ee8a8ac 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -162,7 +162,7 @@ Chat - Assistant + Assistente Moderazione diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 8a89b2ecf..d8c95b591 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -159,10 +159,10 @@ Kontakty - Chat + Czat - Assistant + Asystent Moderacja diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 365f1d326..c9761d735 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -159,10 +159,10 @@ Kontakter - Chat + Chatt - Assistant + Assistent Moderering diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 769a1e921..1436f122f 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -159,10 +159,10 @@ Контакти - Chat + Чат - Assistant + Асистент Модерація diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index 165ed8bd2..09c2eaed1 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -151,6 +151,9 @@ public interface IChatPermissionService /// Task> ResolveChannelAudienceUserIdsAsync(ChatChannel channel); + /// True when the user can administer the department (used to widen provisioning/visibility, e.g. every group default channel). + Task IsDepartmentAdminAsync(int departmentId, string userId); + /// Drops cached permission evaluations for a channel (membership/roles changed) and bumps the channel-list cache version. Task InvalidateChannelCacheAsync(string chatChannelId); } diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 941e7e79d..416276dab 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -84,12 +84,30 @@ async Task> getChannels() if (departmentChannel != null) results[departmentChannel.ChatChannelId] = departmentChannel; - var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); - if (group != null) + // Department admins get every group's default channel (provisioned lazily right here); + // everyone else gets only the group they belong to. + if (await _chatPermissionService.IsDepartmentAdminAsync(departmentId, userId)) { - var groupChannel = await EnsureGroupChannelAsync(group); - if (groupChannel != null) - results[groupChannel.ChatChannelId] = groupChannel; + var allGroups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId); + if (allGroups != null) + { + foreach (var departmentGroup in allGroups) + { + var groupChannel = await EnsureGroupChannelAsync(departmentGroup); + if (groupChannel != null) + results[groupChannel.ChatChannelId] = groupChannel; + } + } + } + else + { + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + if (group != null) + { + var groupChannel = await EnsureGroupChannelAsync(group); + if (groupChannel != null) + results[groupChannel.ChatChannelId] = groupChannel; + } } // Chatbot channels are provisioned when a chatbot session starts — the list path only diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs index 22226fb10..4a22a1bcd 100644 --- a/Core/Resgrid.Services/ChatPermissionService.cs +++ b/Core/Resgrid.Services/ChatPermissionService.cs @@ -682,7 +682,7 @@ private async Task AddUnitCrewAsync(int unitId, HashSet userIds) AddIfSet(userIds, role.UserId); } - private async Task IsDepartmentAdminAsync(int departmentId, string userId) + public async Task IsDepartmentAdminAsync(int departmentId, string userId) { return await _authorizationService.CanUserModifyDepartmentAsync(userId, departmentId); } diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs index dcd7b7f28..72a3d1492 100644 --- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs @@ -524,6 +524,64 @@ public async Task implicit_channel_without_membership_should_create_row() } } + [TestFixture] + public class when_listing_channels_for_users : with_the_chat_channel_service + { + private ChatChannel SetupDepartmentChannel() + { + var departmentChannel = new ChatChannel + { + ChatChannelId = "dept-chan", + DepartmentId = 1, + ChannelType = (int)ChatChannelType.DepartmentDefault, + Name = "First Battalion", + CreatedOn = DateTime.UtcNow + }; + _chatChannelRepositoryMock.Setup(x => x.GetDepartmentDefaultAsync(1)).ReturnsAsync(departmentChannel); + return departmentChannel; + } + + [Test] + public async Task department_admin_should_get_every_group_channel_provisioned() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "admin-a")).ReturnsAsync(true); + _departmentGroupsServiceMock.Setup(x => x.GetAllGroupsForDepartmentAsync(1)).ReturnsAsync(new List + { + new DepartmentGroup { DepartmentGroupId = 9, DepartmentId = 1, Name = "Station 1" }, + new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = 1, Name = "Station 2" } + }); + _chatChannelRepositoryMock.Setup(x => x.GetByGroupIdAsync(It.IsAny())).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "admin-a", null); + + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId == 9); + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId == 10); + _chatChannelRepositoryMock.Verify(x => x.InsertAsync(It.Is(c => c.ChannelType == (int)ChatChannelType.GroupDefault), + It.IsAny(), It.IsAny()), Times.Exactly(2)); + _departmentGroupsServiceMock.Verify(x => x.GetGroupForUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task non_admin_should_only_get_their_own_group_channel() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + _departmentGroupsServiceMock.Setup(x => x.GetGroupForUserAsync("user-a", 1)).ReturnsAsync(new DepartmentGroup + { + DepartmentGroupId = 9, + DepartmentId = 1, + Name = "Station 1" + }); + _chatChannelRepositoryMock.Setup(x => x.GetByGroupIdAsync(9)).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null); + + result.Should().ContainSingle(c => c.ChannelType == (int)ChatChannelType.GroupDefault).Which.GroupId.Should().Be(9); + _departmentGroupsServiceMock.Verify(x => x.GetAllGroupsForDepartmentAsync(It.IsAny()), Times.Never); + } + } + [TestFixture] public class when_creating_ad_hoc_group_channels : with_the_chat_channel_service { diff --git a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj index 8461bfa4f..744eecaae 100644 --- a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj +++ b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj @@ -62,6 +62,8 @@ + + diff --git a/Web/Resgrid.Web.Eventing/Startup.cs b/Web/Resgrid.Web.Eventing/Startup.cs index ff3c063da..2bcce853e 100644 --- a/Web/Resgrid.Web.Eventing/Startup.cs +++ b/Web/Resgrid.Web.Eventing/Startup.cs @@ -332,6 +332,10 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new MarketingModule()); builder.RegisterModule(new PdfProviderModule()); builder.RegisterModule(new MessagingProviderModule()); + // The chat hub's service chain (ChatChannelService -> ChatPermissionService -> + // IncidentCommandService -> IncidentVoiceService) needs the weather and voip providers. + builder.RegisterModule(new Resgrid.Providers.Voip.VoipProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); diff --git a/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs b/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs index cb4361ecb..96f22b5b2 100644 --- a/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs +++ b/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs @@ -57,6 +57,27 @@ public Task CheckHealthAsync(HealthCheckContext context, Canc validationErrors.Add("Redis connection string is not configured."); } + // Synthesis writes Piper/ffmpeg intermediates under TempDirectory; with a + // read-only root filesystem this only works when the temp volume is actually + // mounted, so prove writability here where the k8s probes will see it fail + // instead of surfacing as 500s on the first uncached prompt. + try + { + var tempRoot = Path.GetFullPath(string.IsNullOrWhiteSpace(_ttsOptions.TempDirectory) + ? Path.GetTempPath() + : _ttsOptions.TempDirectory); + + Directory.CreateDirectory(tempRoot); + + var probeFilePath = Path.Combine(tempRoot, $"health-probe-{Guid.NewGuid():N}.tmp"); + File.WriteAllBytes(probeFilePath, new byte[] { 1 }); + File.Delete(probeFilePath); + } + catch (Exception ex) + { + validationErrors.Add($"The TTS temp directory '{_ttsOptions.TempDirectory}' is not writable: {ex.Message}"); + } + if (validationErrors.Count == 0) { return Task.FromResult(HealthCheckResult.Healthy("TTS configuration is ready.")); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/AssistantPanelElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/AssistantPanelElement.tsx new file mode 100644 index 000000000..00630a594 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/AssistantPanelElement.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import './chat.css'; +import ChatbotElement from './ChatbotElement'; +import { useChatStore } from './useChatStore'; + +export interface AssistantPanelElementProps { + hostElement?: HTMLElement; + // Localized strings supplied by the Razor host via element attributes (commonLocalizer). + label?: string; + closeLabel?: string; +} + +// Footer button + right-hand slide-out drawer hosting the assistant conversation. The assistant +// is intentionally not a standalone page: the drawer overlays whatever the user is working on. +export default function AssistantPanelElement({ hostElement, label = 'Assistant', closeLabel = 'Close' }: AssistantPanelElementProps) { + // Piggybacks on the chat store populated by 's bootstrap (both elements share the + // module store), so this element never issues its own channel fetch just to gate visibility. + const available = useChatStore((state) => state.chatAvailable); + const loaded = useChatStore((state) => state.channelsLoaded); + const [open, setOpen] = useState(false); + + const ready = loaded && available; + + useEffect(() => { + if (hostElement) { + hostElement.style.display = ready ? '' : 'none'; + } + }, [hostElement, ready]); + + useEffect(() => { + if (!open) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open]); + + if (!ready) { + return null; + } + + return ( + <> + + {open && ( +
+
+
+ + {label} +
+ +
+
+ {/* Mounted only while open, so the chatbot channel/session is provisioned lazily on first use. */} + +
+
+ )} + + ); +} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx index 7d3fd40d3..f510d9efa 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx @@ -9,6 +9,8 @@ interface ChannelListProps { activeChannelId: string | null; filter?: string; loading?: boolean; + loadFailed?: boolean; + onRetry?: () => void; onSelect: (channelId: string) => void; } @@ -56,9 +58,24 @@ export function ChannelListSkeleton() { ); } -export default function ChannelList({ channels, activeChannelId, filter, loading, onSelect }: ChannelListProps) { +export default function ChannelList({ channels, activeChannelId, filter, loading, loadFailed, onRetry, onSelect }: ChannelListProps) { const messagesByChannel = useChatStore((state) => state.messagesByChannel); + // Load failures leave channelsLoaded false, so check the error state before the skeleton. + if (loadFailed) { + return ( +
+ +
Couldn't load conversations.
+ {onRetry && ( + + )} +
+ ); + } + if (loading) { return ; } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx index b931b2ca7..a2043ce9a 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx @@ -24,7 +24,7 @@ export interface ChatPageElementProps { } export default function ChatPageElement(_props: ChatPageElementProps) { - const { available, loaded } = useChatBootstrap({ connectImmediately: true }); + const { available, loaded, loadFailed, reload } = useChatBootstrap({ connectImmediately: true }); const channels = useChatStore((state) => state.channels, shallowArrayEqual); const [activeChannelId, setActiveChannelId] = useState(null); @@ -114,7 +114,15 @@ export default function ChatPageElement(_props: ChatPageElementProps) { + - +
diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx index 06a8aa9d7..fd9060503 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx @@ -15,10 +15,12 @@ import { NoticeToast, AuthErrorNotice } from './atoms/StatusBanners'; export interface ChatPanelElementProps { hostElement?: HTMLElement; + // Localized label supplied by the Razor host via the element attribute (commonLocalizer). + label?: string; } -export default function ChatPanelElement({ hostElement }: ChatPanelElementProps) { - const { available, loaded, connect } = useChatBootstrap(); +export default function ChatPanelElement({ hostElement, label = 'Chat' }: ChatPanelElementProps) { + const { available, loaded, loadFailed, reload, connect } = useChatBootstrap(); const channels = useChatStore((state) => state.channels, shallowArrayEqual); const unread = useChatStore((state) => state.channels.reduce((total, channel) => total + Math.max(0, channel.UnreadCount), 0)); @@ -32,11 +34,15 @@ export default function ChatPanelElement({ hostElement }: ChatPanelElementProps) const currentUserId = getCurrentUserId(); const activeChannel = channels.find((channel) => channel.ChatChannelId === activeChannelId) ?? null; + // Feature-flag gate: the footer button stays hidden until the server confirms the + // Chat.System flag is on for this department (GetChannels 404s when it is off). + const chatReady = loaded && available; + useEffect(() => { if (hostElement) { - hostElement.style.display = loaded && !available ? 'none' : ''; + hostElement.style.display = chatReady ? '' : 'none'; } - }, [hostElement, loaded, available]); + }, [hostElement, chatReady]); const openPanel = () => { setOpen(true); @@ -50,17 +56,19 @@ export default function ChatPanelElement({ hostElement }: ChatPanelElementProps) setThread(null); }; - if (loaded && !available) { + if (!chatReady) { return null; } if (!open) { + // Collapsed state renders inline inside the site footer (see _Footer.cshtml) so the + // button never overlaps page content the way the old floating FAB did. return ( -
- + )} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css index 211e6bf3c..dfdf58c78 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css @@ -39,36 +39,41 @@ border: 0; } -/* ---- Floating panel ---- */ -.rgchat-fab { - position: fixed; - right: 24px; - bottom: 24px; - z-index: 12000; +/* ---- Footer toggle button ---- */ +rg-chat { + display: inline-block; + vertical-align: middle; +} + +/* Rendered inline inside the fixed site footer (right side), so it never overlaps page + content. The footer stays light in both color schemes, so colors are fixed here. */ +.rgchat-footerbtn { display: inline-flex; align-items: center; - gap: 8px; - padding: 12px 18px; + gap: 6px; + padding: 4px 14px; border: none; border-radius: 999px; - background: var(--rgchat-dark); + background: #1ab394; color: #fff; - font-size: 14px; + font-size: 13px; font-weight: 600; + line-height: 20px; cursor: pointer; - box-shadow: 0 10px 30px rgba(15, 23, 42, 0.28); - transition: transform 160ms ease, box-shadow 160ms ease; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18); + transition: background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; } -.rgchat-fab:hover { +.rgchat-footerbtn:hover { + background: #18a689; transform: translateY(-1px); - box-shadow: 0 14px 34px rgba(15, 23, 42, 0.34); + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.24); } -.rgchat-fab__badge { - min-width: 20px; - height: 20px; - padding: 0 6px; +.rgchat-footerbtn__badge { + min-width: 18px; + height: 18px; + padding: 0 5px; border-radius: 999px; background: var(--rgchat-urgent); color: #fff; @@ -79,14 +84,16 @@ justify-content: center; } +/* ---- Floating panel ---- */ .rgchat-panel { position: fixed; right: 24px; - bottom: 24px; + /* Clears the fixed site footer so the panel never covers the copyright bar. */ + bottom: 56px; z-index: 12000; width: 384px; height: 560px; - max-height: calc(100vh - 48px); + max-height: calc(100vh - 80px); display: flex; flex-direction: column; background: var(--rgchat-panel); @@ -1370,6 +1377,78 @@ } } +/* ---- Assistant footer button + slide-out drawer ---- */ +rg-assistant { + display: inline-block; + vertical-align: middle; + margin-right: 8px; +} + +.rgchat-footerbtn--assistant { + background: #2f4050; +} + +.rgchat-footerbtn--assistant:hover { + background: #263544; +} + +.rgchat-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 12050; + width: 420px; + max-width: calc(100vw - 24px); + display: flex; + flex-direction: column; + background: var(--rgchat-panel); + border-left: 1px solid var(--rgchat-border); + box-shadow: -18px 0 48px rgba(15, 23, 42, 0.25); + animation: rgchat-drawer-in 200ms ease; +} + +@keyframes rgchat-drawer-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.rgchat-drawer__head { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--rgchat-dark); + color: #fff; +} + +.rgchat-drawer__head .rgchat-panel__title { + flex: 1; +} + +.rgchat-drawer__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* The embedded chatbot fills the drawer edge-to-edge instead of its standalone framed layout. */ +.rgchat-drawer__body .rgchat-root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.rgchat-drawer__body .rgchat-botframe { + flex: 1; + height: auto; + min-height: 0; + border: 0; + border-radius: 0; +} + /* ---- Chatbot ---- */ .rgchat-botframe { height: calc(100vh - 220px); @@ -1552,58 +1631,9 @@ margin: 8px 0; } -/* ---- Dark mode ---- */ -@media (prefers-color-scheme: dark) { - .rgchat-root { - --rgchat-dark: #141c26; - --rgchat-bg: #131a23; - --rgchat-panel: #1c2632; - --rgchat-border: #2c3a4a; - --rgchat-muted: #9aa8b8; - --rgchat-ink: #e6edf4; - --rgchat-own-bg: #123c33; - --rgchat-own-ink: #d9f3ea; - --rgchat-own-border: #1f5c4c; - --rgchat-hover: rgba(255, 255, 255, 0.07); - --rgchat-bubble-bg: #223041; - --rgchat-input-bg: #141c26; - --rgchat-shadow-pop: 0 12px 30px rgba(0, 0, 0, 0.55); - } - - .rgchat-daydivider { - background: rgba(255, 255, 255, 0.09); - } - - .rgchat-chan--active { - background: rgba(26, 179, 148, 0.2); - } - - .rgchat-reaction--mine { - background: rgba(26, 179, 148, 0.22); - } - - .rgchat-skeleton { - background: linear-gradient(90deg, rgba(255, 255, 255, 0.06) 25%, rgba(255, 255, 255, 0.14) 50%, rgba(255, 255, 255, 0.06) 75%); - background-size: 200% 100%; - } - - .rgchat-bot .rgchat-bubble--bot { - background: #223152; - border-color: #31436e; - color: #e6edf4; - } - - .rgchat-tag { - background: rgba(255, 255, 255, 0.12); - color: #cdd8e4; - } - - .rgchat-error { - background: rgba(216, 54, 76, 0.14); - border-color: rgba(216, 54, 76, 0.4); - color: #f3b4bd; - } -} +/* NOTE: no dark-mode block on purpose. The Resgrid web app (Inspinia md-skin) is light-only, so the + chat surfaces stay light regardless of the OS color-scheme preference — a dark chat pane on the + white app chrome looked broken. */ /* ---- Reduced motion ---- */ @media (prefers-reduced-motion: reduce) { diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts index 0ceae8a90..b9fe1037c 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts @@ -23,6 +23,7 @@ export type ChatConnectionStatus = 'connected' | 'reconnecting' | 'offline'; export interface ChatState { chatAvailable: boolean; channelsLoaded: boolean; + channelsLoadFailed: boolean; channels: ChatChannelDto[]; messagesByChannel: Record; threadMessagesByRoot: Record; @@ -46,6 +47,7 @@ const TYPING_TTL_MS = 5000; let state: ChatState = { chatAvailable: true, channelsLoaded: false, + channelsLoadFailed: false, channels: [], messagesByChannel: {}, threadMessagesByRoot: {}, @@ -132,7 +134,13 @@ export function setChatAvailable(available: boolean): void { } export function setChannels(channels: ChatChannelDto[]): void { - setState({ channels, channelsLoaded: true }); + setState({ channels, channelsLoaded: true, channelsLoadFailed: false }); +} + +export function setChannelsLoadFailed(failed: boolean): void { + if (state.channelsLoadFailed !== failed) { + setState({ channelsLoadFailed: failed }); + } } export function upsertChannel(channel: ChatChannelDto): void { diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts index 6807ab2b4..85fe94c49 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts @@ -1,13 +1,14 @@ import { useCallback, useEffect, useRef } from 'react'; import { getChannels, getMyPendingAcks } from './chatApi'; import { chatHub } from './chatHub'; -import { setChannels, setChatAvailable, setPendingAcks, chatStore } from './chatStore'; +import { setChannels, setChannelsLoadFailed, setChatAvailable, setPendingAcks, chatStore } from './chatStore'; import { seedPresenceFor } from './chatActions'; import { useChatStore } from './useChatStore'; export interface ChatBootstrap { available: boolean; loaded: boolean; + loadFailed: boolean; reload: () => void; connect: () => void; } @@ -35,6 +36,7 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap const connectedRef = useRef(false); const reload = useCallback(() => { + setChannelsLoadFailed(false); getChannels() .then((outcome) => { setChatAvailable(outcome.available); @@ -43,7 +45,12 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap void seedPresenceFor(outcome.channels.map((channel) => channel.OwnerUserId)); } }) - .catch((error) => console.error('Failed to load chat channels.', error)); + .catch((error) => { + // Non-404 failure (network, 500, expired token): surface a retryable error state + // instead of leaving the skeleton loader up forever. + setChannelsLoadFailed(true); + console.error('Failed to load chat channels.', error); + }); }, []); const connect = useCallback(() => { @@ -69,9 +76,11 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap .then((acks) => setPendingAcks(acks.map((ack) => ack.ChatMessageId))) .catch(() => undefined); - // Badge freshness while the hub is disconnected (FAB never opened on this page). + // Badge freshness while the hub is disconnected (panel never opened on this page). + // Skip once the server said chat is unavailable (404) — no point re-asking every poll. const poll = setInterval(() => { - if (chatStore.getState().connectionStatus !== 'connected') { + const state = chatStore.getState(); + if (state.chatAvailable && state.connectionStatus !== 'connected') { reload(); } }, BADGE_POLL_INTERVAL_MS); @@ -84,5 +93,6 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap const available = useChatStore((state) => state.chatAvailable); const loaded = useChatStore((state) => state.channelsLoaded); - return { available, loaded, reload, connect }; + const loadFailed = useChatStore((state) => state.channelsLoadFailed); + return { available, loaded, loadFailed, reload, connect }; } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts b/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts index fb16d2244..d328425e3 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts @@ -4,6 +4,7 @@ import type { MapElementProps } from './components/map/MapElement'; import type { ShiftsCalendarElementProps } from './components/shifts/ShiftsCalendarElement'; import type { OmnibarElementProps } from './components/omnibar/OmnibarElement'; import type { ChatPanelElementProps } from './components/chat/ChatPanelElement'; +import type { AssistantPanelElementProps } from './components/chat/AssistantPanelElement'; import type { ChatPageElementProps } from './components/chat/ChatPageElement'; import type { ChatbotElementProps } from './components/chat/ChatbotElement'; import type { ChatModerationElementProps } from './components/chat/ChatModerationElement'; @@ -51,7 +52,16 @@ defineReactElement( defineReactElement( 'rg-chat', () => import('./components/chat/ChatPanelElement'), - [], + [{ attribute: 'label', property: 'label', type: 'string', defaultValue: 'Chat' }], +); + +defineReactElement( + 'rg-assistant', + () => import('./components/chat/AssistantPanelElement'), + [ + { attribute: 'label', property: 'label', type: 'string', defaultValue: 'Assistant' }, + { attribute: 'closelabel', property: 'closeLabel', type: 'string', defaultValue: 'Close' }, + ], ); defineReactElement( diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs index b59606138..ad230a84f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs @@ -1,6 +1,8 @@ +using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Resgrid.Providers.Claims; +using Resgrid.Model; +using Resgrid.Model.Services; namespace Resgrid.Web.Areas.User.Controllers { @@ -8,22 +10,38 @@ namespace Resgrid.Web.Areas.User.Controllers [Authorize] public class ChatController : SecureBaseController { + private readonly IFeatureToggleService _featureToggleService; + + public ChatController(IFeatureToggleService featureToggleService) + { + _featureToggleService = featureToggleService; + } + + private Task ChatEnabledAsync() + { + return _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId); + } + /// /// Full-page Slack-style chat workspace, rendered by the <rg-chat-page> React element. /// [HttpGet] - public IActionResult Index() + public async Task Index() { + if (!await ChatEnabledAsync()) + return RedirectToAction("Dashboard", "Home", new { Area = "User" }); + return View(); } /// - /// AI assistant conversation, rendered by the <rg-chatbot> React element. + /// The assistant is no longer a standalone page: it lives in the <rg-assistant> footer + /// slide-out on every page. Old links land on the chat workspace. /// [HttpGet] public IActionResult Chatbot() { - return View(); + return RedirectToAction("Index"); } /// diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs index 3a3e7dba5..cd9c0f618 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs @@ -11,15 +11,20 @@ namespace Resgrid.Web.Areas.User.Controllers public class ModerationController : SecureBaseController { private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IFeatureToggleService _featureToggleService; - public ModerationController(IDepartmentGroupsService departmentGroupsService) + public ModerationController(IDepartmentGroupsService departmentGroupsService, IFeatureToggleService featureToggleService) { _departmentGroupsService = departmentGroupsService; + _featureToggleService = featureToggleService; } [HttpGet] public async Task Index() { + if (!await _featureToggleService.IsEnabledAsync(Resgrid.Model.FeatureFlagKeys.ChatSystem, DepartmentId)) + return RedirectToAction("Dashboard", "Home", new { Area = "User" }); + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && !await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId)) return Unauthorized(); diff --git a/Web/Resgrid.Web/Areas/User/Views/Chat/Chatbot.cshtml b/Web/Resgrid.Web/Areas/User/Views/Chat/Chatbot.cshtml deleted file mode 100644 index c096ecbe7..000000000 --- a/Web/Resgrid.Web/Areas/User/Views/Chat/Chatbot.cshtml +++ /dev/null @@ -1,34 +0,0 @@ -@{ - ViewBag.Title = "Resgrid | " + commonLocalizer["AssistantModule"]; - Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; -} - -
-
-

@commonLocalizer["AssistantModule"]

- -
-
- -
-
-
-
-
- -
-
-
-
-
- -@section Scripts -{ -} diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml index 1dc44aba9..eba1eb545 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml @@ -1,8 +1,9 @@  diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index bde9393c0..bb49d0e58 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -1,4 +1,10 @@ -