From 032d52a0622e8883c963e42dc57525d8ff0d56c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schie=C3=9Fl?= <150372753+johannesschiessl@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:13:39 +0200 Subject: [PATCH 1/2] Add dedicated chat and preset routes --- apps/web/src/chats/ChatNavigation.ts | 8 +- .../components/chats/ChatAnswerPrompts.tsx | 134 ++++++++ apps/web/src/components/chats/ChatDrawer.tsx | 94 +----- .../src/components/chats/ChatPresetDialog.tsx | 6 +- .../components/chats/ChatPresetsWorkspace.tsx | 297 ++++++++++++++++++ apps/web/src/components/shows/ShowLayout.tsx | 136 ++++---- apps/web/src/routeTree.gen.ts | 42 +++ apps/web/src/routes/shows/$showId/chat.tsx | 32 ++ apps/web/src/routes/shows/$showId/presets.tsx | 10 + 9 files changed, 588 insertions(+), 171 deletions(-) create mode 100644 apps/web/src/components/chats/ChatAnswerPrompts.tsx create mode 100644 apps/web/src/components/chats/ChatPresetsWorkspace.tsx create mode 100644 apps/web/src/routes/shows/$showId/chat.tsx create mode 100644 apps/web/src/routes/shows/$showId/presets.tsx diff --git a/apps/web/src/chats/ChatNavigation.ts b/apps/web/src/chats/ChatNavigation.ts index f7d6488..a591ad6 100644 --- a/apps/web/src/chats/ChatNavigation.ts +++ b/apps/web/src/chats/ChatNavigation.ts @@ -5,7 +5,9 @@ export type { ChatOpenRequest } from "./ChatNavigationState"; const eventName = "showtime-chat-open"; -const activeShowId = () => { +const activeChatShowId = () => { + const pathname = router.state.location.pathname; + if (!pathname.includes("/live/") && !pathname.endsWith("/chat")) return undefined; for (const match of router.state.matches) { const showId = (match.params as { readonly showId?: unknown }).showId; if (typeof showId === "string") return showId; @@ -14,8 +16,8 @@ const activeShowId = () => { }; const chatNavigation = makeChatNavigation({ - getActiveShowId: activeShowId, - navigateToShow: (showId) => router.navigate({ to: "/shows/$showId", params: { showId } }), + getActiveShowId: activeChatShowId, + navigateToShow: (showId) => router.navigate({ to: "/shows/$showId/chat", params: { showId } }), publishOpenRequest: () => window.dispatchEvent(new Event(eventName)), }); diff --git a/apps/web/src/components/chats/ChatAnswerPrompts.tsx b/apps/web/src/components/chats/ChatAnswerPrompts.tsx new file mode 100644 index 0000000..3f9e2c7 --- /dev/null +++ b/apps/web/src/components/chats/ChatAnswerPrompts.tsx @@ -0,0 +1,134 @@ +import * as React from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { ChatSnapshot, Profile, ShowId } from "@showtime/contracts"; +import { chatAtoms, profileAtoms } from "@/client"; +import { registerChatAnswerDialog } from "@/chats/ChatAnswerDialogPresence"; +import { + planChatAnswerRequests, + type AnswerRequest, + type ChatAnswerRequestSequences, +} from "@/chats/ChatAnswerRequestPolicy"; +import { ChatPresetAnswerDialog } from "@/components/chats/ChatPresetAnswer"; +import { useSelectedProfile } from "@/profiles"; + +export function ChatAnswerPrompts({ + showId, + chatOpen, +}: { + readonly showId: ShowId; + readonly chatOpen: boolean; +}) { + const profilesResult = useAtomValue(profileAtoms.state); + const profileState = AsyncResult.isSuccess(profilesResult) ? profilesResult.value : undefined; + const { selected } = useSelectedProfile(profileState); + + return selected ? ( + + ) : null; +} + +function ProfileChatAnswerPrompts({ + showId, + chatOpen, + profile, + profiles, +}: { + readonly showId: ShowId; + readonly chatOpen: boolean; + readonly profile: Profile; + readonly profiles: ReadonlyArray; +}) { + const result = useAtomValue(chatAtoms(showId, profile.id).state); + const snapshot = AsyncResult.isSuccess(result) ? result.value : undefined; + + return ( + + ); +} + +function ReadyChatAnswerPrompts({ + showId, + chatOpen, + profile, + profiles, + snapshot, +}: { + readonly showId: ShowId; + readonly chatOpen: boolean; + readonly profile: Profile; + readonly profiles: ReadonlyArray; + readonly snapshot?: ChatSnapshot; +}) { + const [pendingAnswers, setPendingAnswers] = React.useState>([]); + const newestSequences = React.useRef(undefined); + + React.useLayoutEffect(() => { + if (chatOpen) return; + return registerChatAnswerDialog(showId, profile.id); + }, [chatOpen, profile.id, showId]); + + React.useEffect(() => { + if (!snapshot) return; + const { requests, sequences } = planChatAnswerRequests({ + channels: snapshot.channels, + profileId: profile.id, + previousSequences: newestSequences.current, + shouldPrompt: !chatOpen, + }); + newestSequences.current = sequences; + if (requests.length > 0) { + setPendingAnswers((current) => [ + ...current, + ...requests.filter((request) => !current.some((item) => item.id === request.id)), + ]); + } + }, [chatOpen, profile.id, snapshot]); + + React.useEffect(() => { + if (chatOpen) setPendingAnswers([]); + }, [chatOpen]); + + const pendingAnswer = pendingAnswers[0]; + const pendingChannel = pendingAnswer + ? snapshot?.channels.find((channel) => channel.id === pendingAnswer.channelId) + : undefined; + const answered = Boolean( + pendingAnswer && + pendingChannel?.messages.some( + (message) => + message.replyToMessageId === pendingAnswer.id && message.senderProfileId === profile.id, + ), + ); + const dismiss = () => setPendingAnswers((current) => current.slice(1)); + + return ( + { + if (!open) dismiss(); + }} + showId={showId} + profileId={profile.id} + request={pendingAnswer} + senderName={ + profiles.find((candidate) => candidate.id === pendingAnswer?.senderProfileId)?.name ?? + "the sender" + } + answered={answered} + onAnswered={dismiss} + /> + ); +} diff --git a/apps/web/src/components/chats/ChatDrawer.tsx b/apps/web/src/components/chats/ChatDrawer.tsx index 1c048e1..15428ce 100644 --- a/apps/web/src/components/chats/ChatDrawer.tsx +++ b/apps/web/src/components/chats/ChatDrawer.tsx @@ -1,17 +1,11 @@ import * as React from "react"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import type { ChatChannelId, ChatSnapshot, Profile, ProfileId, ShowId } from "@showtime/contracts"; +import type { ChatChannelId, Profile, ProfileId, ShowId } from "@showtime/contracts"; import { chatAtoms, profileAtoms } from "@/client"; -import { registerChatAnswerDialog } from "@/chats/ChatAnswerDialogPresence"; -import { - planChatAnswerRequests, - type AnswerRequest, - type ChatAnswerRequestSequences, -} from "@/chats/ChatAnswerRequestPolicy"; import { consumeChatOpenRequest, subscribeChatOpenRequests } from "@/chats/ChatNavigation"; +import { ChatAnswerPrompts } from "@/components/chats/ChatAnswerPrompts"; import { ChatWorkspace } from "@/components/chats/ChatWorkspace"; -import { ChatPresetAnswerDialog } from "@/components/chats/ChatPresetAnswer"; import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher"; import { Badge } from "@/components/ui/badge"; import { @@ -37,12 +31,7 @@ export function ChatDrawer(props: ChatDrawerProps) { const { selected } = useSelectedProfile(profileState); return selected ? ( - + ) : ( ); @@ -50,26 +39,15 @@ export function ChatDrawer(props: ChatDrawerProps) { function ProfileChatDrawer({ profile, - profiles, ...props }: ChatDrawerProps & { readonly profile: Profile; - readonly profiles: ReadonlyArray; }) { const result = useAtomValue(chatAtoms(props.showId, profile.id).state); - const snapshot = AsyncResult.isSuccess(result) ? result.value : undefined; const unreadCount = AsyncResult.isSuccess(result) ? result.value.channels.reduce((total, channel) => total + channel.unreadCount, 0) : 0; - return ( - - ); + return ; } function ChatDrawerView({ @@ -79,21 +57,13 @@ function ChatDrawerView({ onOpenChange, trigger, onSelectedChannelChange, - profile, - profiles = [], - snapshot, }: ChatDrawerProps & { readonly unreadCount: number; - readonly profile?: Profile; - readonly profiles?: ReadonlyArray; - readonly snapshot?: ChatSnapshot; }) { const [internalOpen, setInternalOpen] = React.useState(false); const open = controlledOpen ?? internalOpen; const setOpen = onOpenChange ?? setInternalOpen; const [selectedChannelId, setSelectedChannelId] = React.useState(); - const [pendingAnswers, setPendingAnswers] = React.useState>([]); - const newestSequences = React.useRef(undefined); const selectChannel = React.useCallback( (channelId: ChatChannelId) => { setSelectedChannelId(channelId); @@ -113,11 +83,6 @@ function ChatDrawerView({ return () => query.removeEventListener("change", update); }, []); - React.useLayoutEffect(() => { - if (!profile || open) return; - return registerChatAnswerDialog(showId, profile.id); - }, [open, profile, showId]); - React.useEffect(() => { const openRequestedChat = () => { const request = consumeChatOpenRequest(showId); @@ -129,39 +94,6 @@ function ChatDrawerView({ return subscribeChatOpenRequests(openRequestedChat); }, [selectChannel, setOpen, showId]); - React.useEffect(() => { - if (!snapshot || !profile) return; - const { requests, sequences } = planChatAnswerRequests({ - channels: snapshot.channels, - profileId: profile.id, - previousSequences: newestSequences.current, - shouldPrompt: !open, - }); - newestSequences.current = sequences; - if (requests.length > 0) - setPendingAnswers((current) => [ - ...current, - ...requests.filter((request) => !current.some((item) => item.id === request.id)), - ]); - }, [open, profile, snapshot]); - - React.useEffect(() => { - if (open) setPendingAnswers([]); - }, [open]); - - const pendingAnswer = pendingAnswers[0]; - const pendingChannel = pendingAnswer - ? snapshot?.channels.find((channel) => channel.id === pendingAnswer.channelId) - : undefined; - const pendingAnswered = Boolean( - pendingAnswer && - pendingChannel?.messages.some( - (message) => - message.replyToMessageId === pendingAnswer.id && message.senderProfileId === profile?.id, - ), - ); - const dismissPendingAnswer = () => setPendingAnswers((current) => current.slice(1)); - return ( <> @@ -188,23 +120,7 @@ function ChatDrawerView({ - {profile && ( - { - if (!nextOpen) dismissPendingAnswer(); - }} - showId={showId} - profileId={profile.id} - request={pendingAnswer} - senderName={ - profiles.find((candidate) => candidate.id === pendingAnswer?.senderProfileId)?.name ?? - "the sender" - } - answered={pendingAnswered} - onAnswered={dismissPendingAnswer} - /> - )} + > ); } diff --git a/apps/web/src/components/chats/ChatPresetDialog.tsx b/apps/web/src/components/chats/ChatPresetDialog.tsx index ee7d6a0..a1eb75e 100644 --- a/apps/web/src/components/chats/ChatPresetDialog.tsx +++ b/apps/web/src/components/chats/ChatPresetDialog.tsx @@ -72,7 +72,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -type Mode = +export type ChatPresetDialogMode = | { readonly type: "list" } | { readonly type: "use"; readonly preset: ChatPreset } | { readonly type: "edit"; readonly preset?: ChatPreset } @@ -93,6 +93,7 @@ export function ChatPresetDialog({ profileId, presets, onSend, + initialMode = { type: "list" }, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; @@ -104,8 +105,9 @@ export function ChatPresetDialog({ parts: ReadonlyArray, answer?: ChatPresetAnswer, ) => Promise; + readonly initialMode?: ChatPresetDialogMode; }) { - const [mode, setMode] = React.useState({ type: "list" }); + const [mode, setMode] = React.useState(initialMode); React.useEffect(() => { if (!open) setMode({ type: "list" }); diff --git a/apps/web/src/components/chats/ChatPresetsWorkspace.tsx b/apps/web/src/components/chats/ChatPresetsWorkspace.tsx new file mode 100644 index 0000000..45574c3 --- /dev/null +++ b/apps/web/src/components/chats/ChatPresetsWorkspace.tsx @@ -0,0 +1,297 @@ +import * as React from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { ChatChannelId, ChatPreset, Profile, ShowId } from "@showtime/contracts"; +import { + EllipsisIcon, + FilePlus2Icon, + HashIcon, + LibraryIcon, + MessageCircleReplyIcon, + PencilIcon, + PlayIcon, + Trash2Icon, +} from "lucide-react"; +import { chatAtoms, profileAtoms } from "@/client"; +import { ChatPresetDialog, type ChatPresetDialogMode } from "@/components/chats/ChatPresetDialog"; +import { useSendChatMessage } from "@/components/chats/useSendChatMessage"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { useSelectedProfile } from "@/profiles"; + +type OpenDialog = { + readonly key: string; + readonly mode: ChatPresetDialogMode; +}; + +export function ChatPresetsWorkspace({ showId }: { readonly showId: ShowId }) { + const profilesResult = useAtomValue(profileAtoms.state); + const profileState = AsyncResult.isSuccess(profilesResult) ? profilesResult.value : undefined; + const { selected } = useSelectedProfile(profileState); + + if (!selected) { + return ( + + + + ); + } + + return ; +} + +function ProfileChatPresetsWorkspace({ + showId, + profile, +}: { + readonly showId: ShowId; + readonly profile: Profile; +}) { + const result = useAtomValue(chatAtoms(showId, profile.id).state); + const snapshot = AsyncResult.isSuccess(result) ? result.value : undefined; + const [channelId, setChannelId] = React.useState(); + const channel = snapshot?.channels.find((item) => item.id === channelId) ?? snapshot?.channels[0]; + + React.useEffect(() => { + if (channel && channel.id !== channelId) setChannelId(channel.id); + }, [channel, channelId]); + + if (!snapshot || !channel) { + return ( + + + + {AsyncResult.isFailure(result) ? : } + + + {AsyncResult.isFailure(result) ? "Presets could not be loaded" : "Loading presets"} + + {AsyncResult.isFailure(result) && ( + Check the connection and try again. + )} + + + ); + } + + return ( + + ); +} + +function ReadyChatPresetsWorkspace({ + showId, + profile, + channelId, + channels, + presets, + onChannelChange, +}: { + readonly showId: ShowId; + readonly profile: Profile; + readonly channelId: ChatChannelId; + readonly channels: ReadonlyArray<{ readonly id: ChatChannelId; readonly name: string }>; + readonly presets: ReadonlyArray; + readonly onChannelChange: (channelId: ChatChannelId) => void; +}) { + const [dialog, setDialog] = React.useState(); + const { sendMessage } = useSendChatMessage(showId, profile.id, channelId); + const open = (mode: ChatPresetDialogMode) => + setDialog({ + key: `${mode.type}:${"preset" in mode && mode.preset ? mode.preset.id : "new"}`, + mode, + }); + + return ( + + + + + + Message presets + + + Prepare common messages once, then send them with the right details in a few taps. + + + + + Send to + value && onChannelChange(value)}> + + + + + {channels.map((channel) => ( + + + {channel.name} + + + ))} + + + + open({ type: "edit" })}> + New preset + + + + + {presets.length === 0 ? ( + + + + + + No presets yet + + Create a reusable message for checks, routing changes, or anything your team sends + often. + + + + open({ type: "edit" })}> + Create first preset + + + + ) : ( + + {presets.map((preset) => ( + open({ type: "use", preset })} + onEdit={() => open({ type: "edit", preset })} + onDelete={() => open({ type: "delete", preset })} + /> + ))} + + )} + + {dialog && ( + { + if (!nextOpen) setDialog(undefined); + }} + showId={showId} + profileId={profile.id} + presets={presets} + initialMode={dialog.mode} + onSend={(body, parts, answer) => + sendMessage(body, parts, answer ? { answer } : undefined) + } + /> + )} + + ); +} + +function PresetCard({ + preset, + onUse, + onEdit, + onDelete, +}: { + readonly preset: ChatPreset; + readonly onUse: () => void; + readonly onEdit: () => void; + readonly onDelete: () => void; +}) { + return ( + + + {preset.name} + + {preset.template} + + + + + } + > + + + + + Edit + + + Delete + + + + + + + {preset.fields.map((field) => ( + + {field.name} + + ))} + {preset.answer && ( + + Reply requested + + )} + {preset.fields.length === 0 && !preset.answer && ( + Ready to send as-is + )} + + + + Use preset + + + + ); +} diff --git a/apps/web/src/components/shows/ShowLayout.tsx b/apps/web/src/components/shows/ShowLayout.tsx index 37fd367..0097fbd 100644 --- a/apps/web/src/components/shows/ShowLayout.tsx +++ b/apps/web/src/components/shows/ShowLayout.tsx @@ -32,21 +32,19 @@ import { Badge } from "../ui/badge"; import { useShowFromParams } from "@/hooks/useShowFromParams"; import { AsyncResult } from "effect/unstable/reactivity"; import { Option } from "effect"; -import type { ChatChannelId, ShowId, SongId } from "@showtime/contracts"; +import type { ShowId, SongId } from "@showtime/contracts"; import { useAtomValue } from "@effect/atom-react"; import { songAtoms } from "@/client"; import { useCreateSong } from "@/components/songs/useCreateSong"; import { createdSongHandoff } from "@/components/songs/CreatedSongHandoff"; import { ShowPageAction } from "./ShowPageAction"; import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher"; -import { ChatDrawer, ChatUnreadBadge } from "@/components/chats/ChatDrawer"; -import { ChatPresetLauncher } from "@/components/chats/ChatPresetLauncher"; +import { ChatUnreadBadge } from "@/components/chats/ChatDrawer"; +import { ChatAnswerPrompts } from "@/components/chats/ChatAnswerPrompts"; import { ConnectionDialog } from "@/components/connections/ConnectionDialog"; import { ScrollArea } from "@/components/ui/scroll-area"; export function ShowLayout() { - const [chatOpen, setChatOpen] = React.useState(false); - const [selectedChannelId, setSelectedChannelId] = React.useState(); const { showId = "", show } = useShowFromParams(); const showName = show?.name ?? "Show"; const showColorClassName = showColorClassNames[show?.color ?? "neutral"]; @@ -57,6 +55,7 @@ export function ShowLayout() { const syncedSongsResult = useAtomValue(songAtoms(typedShowId).syncedSongs); const pathname = useRouterState({ select: (state) => state.location.pathname }); const isAllSongsRoute = /\/setlist\/?$/.test(pathname); + const isChatRoute = /\/chat\/?$/.test(pathname); const songs = AsyncResult.isSuccess(songsResult) ? songsResult.value : AsyncResult.isFailure(songsResult) @@ -70,44 +69,7 @@ export function ShowLayout() { const songCreator = useCreateSong(typedShowId, currentSongId); return ( - - ( - - - - )} - /> - setChatOpen(true)} - > - - - - - - > - } - /> + @@ -136,6 +98,19 @@ export function ShowLayout() { label="Mixes" icon={SpeakerIcon} /> + } + /> + @@ -201,20 +176,21 @@ export function ShowLayout() { pathname={pathname} songCreator={songCreator} /> - - + {isChatRoute ? ( + - - setChatOpen(true)} /> + ) : ( + + + + + + )} + - + ); } @@ -262,17 +238,11 @@ function ShowHeader({ ); } -function MobileBottomNavigation({ - showId, - onChatOpen, -}: { - readonly showId: string; - readonly onChatOpen: () => void; -}) { +function MobileBottomNavigation({ showId }: { readonly showId: string }) { return ( - - - Chat - - - - + } + /> + ); } @@ -322,11 +294,18 @@ function MobileNavigationLink({ showId, label, icon: Icon, + badge, }: { - readonly to: "/shows/$showId/microphones" | "/shows/$showId/mixes" | "/shows/$showId/setlist"; + readonly to: + | "/shows/$showId/microphones" + | "/shows/$showId/mixes" + | "/shows/$showId/setlist" + | "/shows/$showId/chat" + | "/shows/$showId/presets"; readonly showId: string; readonly label: string; readonly icon: React.ComponentType<{ className?: string }>; + readonly badge?: React.ReactNode; }) { return ( - {label} + {label} + {badge && {badge}} ); } @@ -348,6 +328,8 @@ type ShowSidebarLinkProps = ( | "/shows/$showId" | "/shows/$showId/microphones" | "/shows/$showId/mixes" + | "/shows/$showId/chat" + | "/shows/$showId/presets" | "/shows/$showId/setlist"; readonly params: { readonly showId: string }; } @@ -357,7 +339,7 @@ type ShowSidebarLinkProps = ( } ) & { readonly label: string; - readonly badge?: string; + readonly badge?: React.ReactNode; readonly number?: number; readonly icon?: React.ComponentType<{ className?: string }>; }; @@ -384,7 +366,7 @@ function ShowSidebarLink({ to, params, label, badge, number, icon: Icon }: ShowS {label} - {badge && {badge}} + {badge && (typeof badge === "string" ? {badge} : badge)} ); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 320cdaa..39d448d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,8 +14,10 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as LiveShowIdRouteImport } from './routes/live/$showId' import { Route as ShowsShowIdRouteRouteImport } from './routes/shows/$showId/route' import { Route as ShowsShowIdIndexRouteImport } from './routes/shows/$showId/index' +import { Route as ShowsShowIdPresetsRouteImport } from './routes/shows/$showId/presets' import { Route as ShowsShowIdMixesRouteImport } from './routes/shows/$showId/mixes' import { Route as ShowsShowIdMicrophonesRouteImport } from './routes/shows/$showId/microphones' +import { Route as ShowsShowIdChatRouteImport } from './routes/shows/$showId/chat' import { Route as ShowsShowIdSetlistIndexRouteImport } from './routes/shows/$showId/setlist/index' import { Route as ShowsShowIdSetlistSongIdRouteImport } from './routes/shows/$showId/setlist/$songId' @@ -44,6 +46,11 @@ const ShowsShowIdIndexRoute = ShowsShowIdIndexRouteImport.update({ path: '/', getParentRoute: () => ShowsShowIdRouteRoute, } as any) +const ShowsShowIdPresetsRoute = ShowsShowIdPresetsRouteImport.update({ + id: '/presets', + path: '/presets', + getParentRoute: () => ShowsShowIdRouteRoute, +} as any) const ShowsShowIdMixesRoute = ShowsShowIdMixesRouteImport.update({ id: '/mixes', path: '/mixes', @@ -54,6 +61,11 @@ const ShowsShowIdMicrophonesRoute = ShowsShowIdMicrophonesRouteImport.update({ path: '/microphones', getParentRoute: () => ShowsShowIdRouteRoute, } as any) +const ShowsShowIdChatRoute = ShowsShowIdChatRouteImport.update({ + id: '/chat', + path: '/chat', + getParentRoute: () => ShowsShowIdRouteRoute, +} as any) const ShowsShowIdSetlistIndexRoute = ShowsShowIdSetlistIndexRouteImport.update({ id: '/setlist/', path: '/setlist/', @@ -71,8 +83,10 @@ export interface FileRoutesByFullPath { '/live': typeof LiveRouteRouteWithChildren '/shows/$showId': typeof ShowsShowIdRouteRouteWithChildren '/live/$showId': typeof LiveShowIdRoute + '/shows/$showId/chat': typeof ShowsShowIdChatRoute '/shows/$showId/microphones': typeof ShowsShowIdMicrophonesRoute '/shows/$showId/mixes': typeof ShowsShowIdMixesRoute + '/shows/$showId/presets': typeof ShowsShowIdPresetsRoute '/shows/$showId/': typeof ShowsShowIdIndexRoute '/shows/$showId/setlist/$songId': typeof ShowsShowIdSetlistSongIdRoute '/shows/$showId/setlist/': typeof ShowsShowIdSetlistIndexRoute @@ -81,8 +95,10 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/live': typeof LiveRouteRouteWithChildren '/live/$showId': typeof LiveShowIdRoute + '/shows/$showId/chat': typeof ShowsShowIdChatRoute '/shows/$showId/microphones': typeof ShowsShowIdMicrophonesRoute '/shows/$showId/mixes': typeof ShowsShowIdMixesRoute + '/shows/$showId/presets': typeof ShowsShowIdPresetsRoute '/shows/$showId': typeof ShowsShowIdIndexRoute '/shows/$showId/setlist/$songId': typeof ShowsShowIdSetlistSongIdRoute '/shows/$showId/setlist': typeof ShowsShowIdSetlistIndexRoute @@ -93,8 +109,10 @@ export interface FileRoutesById { '/live': typeof LiveRouteRouteWithChildren '/shows/$showId': typeof ShowsShowIdRouteRouteWithChildren '/live/$showId': typeof LiveShowIdRoute + '/shows/$showId/chat': typeof ShowsShowIdChatRoute '/shows/$showId/microphones': typeof ShowsShowIdMicrophonesRoute '/shows/$showId/mixes': typeof ShowsShowIdMixesRoute + '/shows/$showId/presets': typeof ShowsShowIdPresetsRoute '/shows/$showId/': typeof ShowsShowIdIndexRoute '/shows/$showId/setlist/$songId': typeof ShowsShowIdSetlistSongIdRoute '/shows/$showId/setlist/': typeof ShowsShowIdSetlistIndexRoute @@ -106,8 +124,10 @@ export interface FileRouteTypes { | '/live' | '/shows/$showId' | '/live/$showId' + | '/shows/$showId/chat' | '/shows/$showId/microphones' | '/shows/$showId/mixes' + | '/shows/$showId/presets' | '/shows/$showId/' | '/shows/$showId/setlist/$songId' | '/shows/$showId/setlist/' @@ -116,8 +136,10 @@ export interface FileRouteTypes { | '/' | '/live' | '/live/$showId' + | '/shows/$showId/chat' | '/shows/$showId/microphones' | '/shows/$showId/mixes' + | '/shows/$showId/presets' | '/shows/$showId' | '/shows/$showId/setlist/$songId' | '/shows/$showId/setlist' @@ -127,8 +149,10 @@ export interface FileRouteTypes { | '/live' | '/shows/$showId' | '/live/$showId' + | '/shows/$showId/chat' | '/shows/$showId/microphones' | '/shows/$showId/mixes' + | '/shows/$showId/presets' | '/shows/$showId/' | '/shows/$showId/setlist/$songId' | '/shows/$showId/setlist/' @@ -177,6 +201,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShowsShowIdIndexRouteImport parentRoute: typeof ShowsShowIdRouteRoute } + '/shows/$showId/presets': { + id: '/shows/$showId/presets' + path: '/presets' + fullPath: '/shows/$showId/presets' + preLoaderRoute: typeof ShowsShowIdPresetsRouteImport + parentRoute: typeof ShowsShowIdRouteRoute + } '/shows/$showId/mixes': { id: '/shows/$showId/mixes' path: '/mixes' @@ -191,6 +222,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShowsShowIdMicrophonesRouteImport parentRoute: typeof ShowsShowIdRouteRoute } + '/shows/$showId/chat': { + id: '/shows/$showId/chat' + path: '/chat' + fullPath: '/shows/$showId/chat' + preLoaderRoute: typeof ShowsShowIdChatRouteImport + parentRoute: typeof ShowsShowIdRouteRoute + } '/shows/$showId/setlist/': { id: '/shows/$showId/setlist/' path: '/setlist' @@ -221,16 +259,20 @@ const LiveRouteRouteWithChildren = LiveRouteRoute._addFileChildren( ) interface ShowsShowIdRouteRouteChildren { + ShowsShowIdChatRoute: typeof ShowsShowIdChatRoute ShowsShowIdMicrophonesRoute: typeof ShowsShowIdMicrophonesRoute ShowsShowIdMixesRoute: typeof ShowsShowIdMixesRoute + ShowsShowIdPresetsRoute: typeof ShowsShowIdPresetsRoute ShowsShowIdIndexRoute: typeof ShowsShowIdIndexRoute ShowsShowIdSetlistSongIdRoute: typeof ShowsShowIdSetlistSongIdRoute ShowsShowIdSetlistIndexRoute: typeof ShowsShowIdSetlistIndexRoute } const ShowsShowIdRouteRouteChildren: ShowsShowIdRouteRouteChildren = { + ShowsShowIdChatRoute: ShowsShowIdChatRoute, ShowsShowIdMicrophonesRoute: ShowsShowIdMicrophonesRoute, ShowsShowIdMixesRoute: ShowsShowIdMixesRoute, + ShowsShowIdPresetsRoute: ShowsShowIdPresetsRoute, ShowsShowIdIndexRoute: ShowsShowIdIndexRoute, ShowsShowIdSetlistSongIdRoute: ShowsShowIdSetlistSongIdRoute, ShowsShowIdSetlistIndexRoute: ShowsShowIdSetlistIndexRoute, diff --git a/apps/web/src/routes/shows/$showId/chat.tsx b/apps/web/src/routes/shows/$showId/chat.tsx new file mode 100644 index 0000000..34ac01a --- /dev/null +++ b/apps/web/src/routes/shows/$showId/chat.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import type { ChatChannelId, ShowId } from "@showtime/contracts"; +import { consumeChatOpenRequest, subscribeChatOpenRequests } from "@/chats/ChatNavigation"; +import { ChatWorkspace } from "@/components/chats/ChatWorkspace"; + +export const Route = createFileRoute("/shows/$showId/chat")({ component: RouteComponent }); + +function RouteComponent() { + const { showId } = Route.useParams(); + const typedShowId = showId as ShowId; + const [requestedChannelId, setRequestedChannelId] = React.useState( + () => consumeChatOpenRequest(typedShowId)?.channelId, + ); + + React.useEffect(() => { + const consumeRequest = () => { + const request = consumeChatOpenRequest(typedShowId); + if (request) setRequestedChannelId(request.channelId); + }; + consumeRequest(); + return subscribeChatOpenRequests(consumeRequest); + }, [typedShowId]); + + return ( + + ); +} diff --git a/apps/web/src/routes/shows/$showId/presets.tsx b/apps/web/src/routes/shows/$showId/presets.tsx new file mode 100644 index 0000000..1ebb932 --- /dev/null +++ b/apps/web/src/routes/shows/$showId/presets.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import type { ShowId } from "@showtime/contracts"; +import { ChatPresetsWorkspace } from "@/components/chats/ChatPresetsWorkspace"; + +export const Route = createFileRoute("/shows/$showId/presets")({ component: RouteComponent }); + +function RouteComponent() { + const { showId } = Route.useParams(); + return ; +} From 3b458f7ae9a0464dc1bcd542a2286b474ff6a69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schie=C3=9Fl?= <150372753+johannesschiessl@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:37:08 +0200 Subject: [PATCH 2/2] Drop stale chat answer prompts --- .../src/chats/ChatAnswerRequestPolicy.test.ts | 24 ++++++++++++++++--- apps/web/src/chats/ChatAnswerRequestPolicy.ts | 20 ++++++++++++++++ .../components/chats/ChatAnswerPrompts.tsx | 11 ++++----- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/web/src/chats/ChatAnswerRequestPolicy.test.ts b/apps/web/src/chats/ChatAnswerRequestPolicy.test.ts index 3042a64..9542632 100644 --- a/apps/web/src/chats/ChatAnswerRequestPolicy.test.ts +++ b/apps/web/src/chats/ChatAnswerRequestPolicy.test.ts @@ -9,7 +9,12 @@ import type { ProfileId, ShowId, } from "@showtime/contracts"; -import { planChatAnswerRequests, type ChatAnswerRequestSequences } from "./ChatAnswerRequestPolicy"; +import { + planChatAnswerRequests, + reconcileChatAnswerRequests, + type AnswerRequest, + type ChatAnswerRequestSequences, +} from "./ChatAnswerRequestPolicy"; const showId = "show_0000000000000001" as ShowId; const channelId = "channel_0000000000000001" as ChatChannelId; @@ -31,14 +36,14 @@ const message = (sequence: number, overrides: Partial = {}): ChatMe ...overrides, }); -const request = (sequence: number, overrides: Partial = {}): ChatMessage => +const request = (sequence: number, overrides: Partial = {}): AnswerRequest => message(sequence, { answer: { template: "{{answer}}" as NonNullable["template"], fields: [{ name: "answer", type: "text" }], }, ...overrides, - }); + }) as AnswerRequest; const channel = (overrides: Partial = {}): ChatChannel => ({ id: channelId, @@ -128,4 +133,17 @@ describe("chat answer request policy", () => { expect(planned.requests).toEqual([]); expect(planned.sequences.get(channelId)).toBe(2); }); + + it("drops queued requests when their channel is deleted", () => { + const stale = request(2); + const available = request(3, { channelId: addedChannelId }); + + const reconciled = reconcileChatAnswerRequests({ + queued: [stale, available], + incoming: [available], + channelIds: new Set([addedChannelId]), + }); + + expect(reconciled).toEqual([available]); + }); }); diff --git a/apps/web/src/chats/ChatAnswerRequestPolicy.ts b/apps/web/src/chats/ChatAnswerRequestPolicy.ts index a74edf8..d3d07d2 100644 --- a/apps/web/src/chats/ChatAnswerRequestPolicy.ts +++ b/apps/web/src/chats/ChatAnswerRequestPolicy.ts @@ -11,6 +11,26 @@ export type AnswerRequest = ChatMessage & { readonly answer: ChatPresetAnswer }; export type ChatAnswerRequestSequences = ReadonlyMap; +export const reconcileChatAnswerRequests = ({ + queued, + incoming, + channelIds, +}: { + readonly queued: ReadonlyArray; + readonly incoming: ReadonlyArray; + readonly channelIds: ReadonlySet; +}): ReadonlyArray => { + const next = queued.filter((request) => channelIds.has(request.channelId)); + for (const request of incoming) { + if (channelIds.has(request.channelId) && !next.some((item) => item.id === request.id)) { + next.push(request); + } + } + return next.length === queued.length && next.every((request, index) => request === queued[index]) + ? queued + : next; +}; + const isAnswerRequest = (message: ChatMessage): message is AnswerRequest => message.answer !== undefined; diff --git a/apps/web/src/components/chats/ChatAnswerPrompts.tsx b/apps/web/src/components/chats/ChatAnswerPrompts.tsx index 3f9e2c7..3304f48 100644 --- a/apps/web/src/components/chats/ChatAnswerPrompts.tsx +++ b/apps/web/src/components/chats/ChatAnswerPrompts.tsx @@ -6,6 +6,7 @@ import { chatAtoms, profileAtoms } from "@/client"; import { registerChatAnswerDialog } from "@/chats/ChatAnswerDialogPresence"; import { planChatAnswerRequests, + reconcileChatAnswerRequests, type AnswerRequest, type ChatAnswerRequestSequences, } from "@/chats/ChatAnswerRequestPolicy"; @@ -89,12 +90,10 @@ function ReadyChatAnswerPrompts({ shouldPrompt: !chatOpen, }); newestSequences.current = sequences; - if (requests.length > 0) { - setPendingAnswers((current) => [ - ...current, - ...requests.filter((request) => !current.some((item) => item.id === request.id)), - ]); - } + const channelIds = new Set(snapshot.channels.map((channel) => channel.id)); + setPendingAnswers((current) => + reconcileChatAnswerRequests({ queued: current, incoming: requests, channelIds }), + ); }, [chatOpen, profile.id, snapshot]); React.useEffect(() => {
+ Prepare common messages once, then send them with the right details in a few taps. +