Keep Pulse search navigation contained and tolerate unavailable media APIs

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-11 04:59:40 -07:00
parent 2d620055e5
commit 8b5a090473
14 changed files with 353 additions and 101 deletions

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { useLocation } from "@tanstack/react-router";
import {
NavigationTargetContext,
type AppNavigationTarget,
} from "./navigation/NavigationTargetContext";
import { workspaceNavigationTarget } from "@/features/pulse/lib/workspaceNavigation";
/** Keep shell-owned dialogs and nested feature navigation in the active workspace. */
export function AppNavigationScope({
children,
}: {
children: React.ReactNode;
}) {
const location = useLocation();
const inherited = React.useContext(NavigationTargetContext);
const search = location.search as { conversation?: string };
const conversation = search.conversation ?? null;
const resolveTarget = React.useCallback(
(target: AppNavigationTarget) =>
workspaceNavigationTarget(target, conversation),
[conversation],
);
return (
<NavigationTargetContext.Provider
value={location.pathname === "/pulse" ? resolveTarget : inherited}
>
{children}
</NavigationTargetContext.Provider>
);
}

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers";
import { useTerminalContext } from "@/app/useTerminalContext";
import { AppNavigationScope } from "@/app/AppNavigationScope";
import { AppShellProvider } from "@/app/AppShellContext";
import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
@@ -106,8 +107,18 @@ import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu";
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
import { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlayProvider";
import { LazySettingsScreen } from "@/app/LazySettingsScreen";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
const EMPTY_CHANNELS: Channel[] = [];
export function AppShell() {
return (
<AppNavigationScope>
<AppShellContent />
</AppNavigationScope>
);
}
function AppShellContent() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
useWebviewScrollBoundaryLock();
@@ -962,6 +973,25 @@ export function AppShell() {
) : null}
</div>
)}
{isPulse && !settingsOpen && (
<TopbarSearch
variant="dialog"
channels={channels}
suggestionChannels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
focusRequest={searchFocusRequest}
onOpenChannel={handleSidebarChannelSelect}
onOpenResult={handleOpenSearchResult}
onOpenUser={async (user) => {
const dm = await openDmMutation.mutateAsync({
pubkeys: [user.pubkey],
});
await goChannel(dm.id);
}}
onBrowseChannels={handleOpenBrowseChannels}
onCreateAgent={() => requestOpenCreateAgent()}
/>
)}
<RequestedAgentCreateDialogs />
<AgentManagementDialogs />
<AppShellOverlays

View File

@@ -31,6 +31,8 @@ import type { RelayEvent } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
/** Render within Pulse's conversation pane with in-place thread navigation. */
embedded?: boolean;
autoSendDraftKey: string | null;
channelId: string;
searchHighlight: SearchHighlightNavigation | null | undefined;
@@ -112,6 +114,7 @@ async function fetchRouteTargetEvents(
}
export function ChannelRouteScreen({
embedded = false,
autoSendDraftKey,
channelId,
searchHighlight,
@@ -295,7 +298,7 @@ export function ChannelRouteScreen({
);
}
if (projectHome && !isHuddleTranscript) {
if (projectHome && !isHuddleTranscript && !embedded) {
return (
<ProjectChannelHome
allowRepositoryHealing={isProjectRelayValidated(projectHome)}
@@ -310,6 +313,7 @@ export function ChannelRouteScreen({
return (
<ChannelScreen
drillInThreads={embedded}
activeChannel={activeChannel}
autoSendDraftKey={autoSendDraftKey}
currentIdentity={identityQuery.data}

View File

@@ -34,6 +34,8 @@ type PulseRouteSearch = ReturnType<typeof parseProjectDetailSearch> & {
post?: string;
reply?: string;
thread?: string;
messageId?: string;
threadRootId?: string;
agentSession?: string;
agentSessionChannel?: string;
channelManagement?: string;
@@ -80,6 +82,8 @@ function validatePulseSearch(
post: stringValue("post"),
reply: stringValue("reply"),
thread: stringValue("thread"),
messageId: stringValue("messageId"),
threadRootId: stringValue("threadRootId"),
agentSession: stringValue("agentSession"),
agentSessionChannel: stringValue("agentSessionChannel"),
channelManagement: stringValue("channelManagement"),

View File

@@ -206,15 +206,10 @@ export function HuddleProvider({
.catch(() => {
/* best-effort */
});
navigator.mediaDevices.addEventListener(
"devicechange",
refreshOutputDevices,
);
const mediaDevices = navigator.mediaDevices;
mediaDevices?.addEventListener("devicechange", refreshOutputDevices);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
refreshOutputDevices,
);
mediaDevices?.removeEventListener("devicechange", refreshOutputDevices);
};
}, []);

View File

@@ -23,8 +23,11 @@ export function useAudioDevices(
// Enumerate audio input devices on mount and when devices change.
React.useEffect(() => {
const mediaDevices = navigator.mediaDevices;
// Media capture may be unavailable in a desktop webview. Chat must still mount.
if (!mediaDevices) return;
function refreshDevices() {
navigator.mediaDevices
mediaDevices
.enumerateDevices()
.then((devices) =>
setAudioDevices(
@@ -41,12 +44,9 @@ export function useAudioDevices(
});
}
refreshDevices();
navigator.mediaDevices.addEventListener("devicechange", refreshDevices);
mediaDevices.addEventListener("devicechange", refreshDevices);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
refreshDevices,
);
mediaDevices.removeEventListener("devicechange", refreshDevices);
};
}, []);

View File

@@ -126,7 +126,16 @@ export function MessageBubbleLayout({
)}
</div>
<div className="max-w-full">{extras}</div>
{footer && <div className="w-full self-start">{footer}</div>}
{footer && (
<div
className={cn(
"flex w-full",
outgoing ? "justify-end" : "justify-start",
)}
>
{footer}
</div>
)}
</div>
</>
);

View File

@@ -211,7 +211,10 @@ export function MessageThreadSummaryRow({
<button
aria-label={summaryAriaLabel}
className="group relative isolate inline-flex h-[1.875rem] w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full py-0 pr-3 text-left text-xs font-medium text-muted-foreground transition-[color,opacity] hover:text-foreground hover:opacity-90 focus-visible:outline-hidden"
className={cn(
"group relative isolate inline-flex h-[1.875rem] w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full py-0 text-left text-xs font-medium text-muted-foreground transition-[color,opacity] hover:text-foreground hover:opacity-90 focus-visible:outline-hidden",
inline ? "focus-visible:ring-2 focus-visible:ring-ring" : "pr-3",
)}
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}
@@ -222,15 +225,17 @@ export function MessageThreadSummaryRow({
}}
type="button"
>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-0.125rem] top-[-0.125rem] rounded-full opacity-0 ring-border/70 transition-[background-color,box-shadow,opacity] group-hover:bg-background/95 group-hover:opacity-100 group-hover:ring-1 group-focus-visible:bg-background/95 group-focus-visible:opacity-100 group-focus-visible:ring-1 group-focus-visible:ring-ring"
data-testid="message-thread-summary-surface"
style={{
left: inline ? 0 : surfaceInsetStart,
right: 0,
}}
/>
{!inline && (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[-0.125rem] top-[-0.125rem] rounded-full opacity-0 ring-border/70 transition-[background-color,box-shadow,opacity] group-hover:bg-background/95 group-hover:opacity-100 group-hover:ring-1 group-focus-visible:bg-background/95 group-focus-visible:opacity-100 group-focus-visible:ring-1 group-focus-visible:ring-ring"
data-testid="message-thread-summary-surface"
style={{
left: surfaceInsetStart,
right: 0,
}}
/>
)}
<div className="relative z-10 flex shrink-0 items-center">
{summary.participants.map((participant, index) => (
<ParticipantAvatar

View File

@@ -5,6 +5,8 @@ export const PULSE_CONVERSATION_KEYS = [
"post",
"reply",
"thread",
"messageId",
"threadRootId",
"profile",
"profileTab",
"profileView",
@@ -17,6 +19,8 @@ export const CLEAR_CONVERSATION_PANELS = {
post: null,
reply: null,
thread: null,
messageId: null,
threadRootId: null,
profile: null,
profileTab: null,
profileView: null,

View File

@@ -53,11 +53,19 @@ export function workspaceNavigationTarget(
params = { workflowId: target.params?.workflowId };
break;
case "/channels/$channelId":
// Special navigation (auto-send, targeted history) retains its canonical route.
if (target.search?.autoSend || target.search?.messageId) return target;
// Draft auto-send still uses the canonical route that owns its lifecycle.
if (target.search?.autoSend) return target;
feed = "conversation";
params = { conversation: target.params?.channelId };
break;
case "/channels/$channelId/posts/$postId":
feed = "conversation";
params = {
conversation: target.params?.channelId,
post: target.params?.postId,
reply: target.search?.replyId,
};
break;
default:
return target;
}

View File

@@ -1,31 +1,39 @@
import * as React from "react";
import { useTerminalContextOverride } from "@/app/TerminalContextOverrideContext";
import { useProfileQuery } from "@/features/profile/hooks";
import { useLocation } from "@tanstack/react-router";
import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, RelayEvent } from "@/shared/api/types";
import type { Channel } from "@/shared/api/types";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
import { MessageBubbleContext } from "@/features/messages/ui/MessageBubbleContext";
import { PULSE_CONVERSATION_KEYS } from "../lib/pulsePanelState";
const ChannelScreen = React.lazy(async () => {
const module = await import("@/features/channels/ui/ChannelScreen");
return { default: module.ChannelScreen };
const ChannelRouteScreen = React.lazy(async () => {
const module = await import("@/app/routes/ChannelRouteScreen");
return { default: module.ChannelRouteScreen };
});
const EMPTY_EVENTS: RelayEvent[] = [];
export function PulseChannelDetail({ channel }: { channel: Channel }) {
export function PulseChannelDetail({
channel,
channelId = channel?.id,
}: {
channel?: Channel;
channelId?: string;
}) {
const identity = useIdentityQuery();
const profile = useProfileQuery();
const searchHighlight = useLocation({
select: selectSearchHighlightRouteState,
});
const detailRef = React.useRef<HTMLDivElement>(null);
const { values, applyPatch } = useHistorySearchState(PULSE_CONVERSATION_KEYS);
const { values } = useHistorySearchState(PULSE_CONVERSATION_KEYS);
const terminalContext = React.useMemo(
() => ({
channelId: channel.id,
channelName: channel.name,
channelId: channelId ?? "",
channelName: channel?.name ?? "Conversation",
threadId: values.thread,
}),
[channel.id, channel.name, values.thread],
[channelId, channel?.name, values.thread],
);
useTerminalContextOverride(terminalContext);
return (
@@ -46,20 +54,19 @@ export function PulseChannelDetail({ channel }: { channel: Channel }) {
</p>
}
>
<ChannelScreen
key={channel.id}
activeChannel={channel}
currentIdentity={identity.data}
currentProfile={profile.data}
drillInThreads
autoSendDraftKey={null}
onCloseForumPost={() => applyPatch({ post: null, reply: null })}
onSelectForumPost={(post) => applyPatch({ post, reply: null })}
selectedForumPostId={values.post}
targetForumReplyId={values.reply}
targetMessageEvents={EMPTY_EVENTS}
targetMessageId={null}
/>
{channelId && (
<ChannelRouteScreen
key={channelId}
embedded
channelId={channelId}
autoSendDraftKey={null}
searchHighlight={searchHighlight}
selectedPostId={values.post}
targetReplyId={values.reply}
targetMessageId={values.messageId}
targetThreadRootId={values.threadRootId ?? values.thread}
/>
)}
</React.Suspense>
</MainInsetProvider>
</MessageBubbleContext.Provider>

View File

@@ -120,7 +120,10 @@ export function PulseConversationSplitView({
: (channels.find((channel) => channel.id === values[selectionKey]) ??
(allMessages ? null : visibleRows[0]?.channel) ??
null);
const selectedId = selected?.id;
const selectedId =
navigation && navigation.view !== "conversation"
? undefined
: (values[selectionKey] ?? selected?.id);
React.useEffect(() => {
if (!values[selectionKey] && selectedId) {
applyPatch({ [selectionKey]: selectedId }, { replace: true });
@@ -187,12 +190,13 @@ export function PulseConversationSplitView({
<button
type="button"
aria-current={
!selected && (!navigation || navigation.view === "conversation")
!selectedId &&
(!navigation || navigation.view === "conversation")
? "true"
: undefined
}
onClick={() => selectConversation(null)}
className={`mb-1 flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${!selected && (!navigation || navigation.view === "conversation") ? "bg-muted/70 font-semibold" : "hover:bg-muted/35"}`}
className={`mb-1 flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${!selectedId && (!navigation || navigation.view === "conversation") ? "bg-muted/70 font-semibold" : "hover:bg-muted/35"}`}
>
<PulseChannelAvatar />
All messages
@@ -282,7 +286,7 @@ export function PulseConversationSplitView({
</p>
)}
</nav>
{!selected && allMessages ? (
{!selectedId && allMessages ? (
<div
ref={allMessages.scrollRef}
className="min-h-0 min-w-0 flex-1 overflow-y-auto"
@@ -300,10 +304,13 @@ export function PulseConversationSplitView({
<div
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid={`${testPrefix}-detail`}
data-channel-id={selected?.id}
data-channel-id={selectedId}
>
{selected ? (
<PulseChannelDetail channel={selected} />
{selectedId ? (
<PulseChannelDetail
channel={selected ?? undefined}
channelId={selectedId}
/>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<MessageCircle aria-hidden className="h-6 w-6" />

View File

@@ -44,7 +44,7 @@ type TopbarSearchProps = {
onCreateChannel?: () => void | Promise<void>;
suggestionChannels?: Channel[];
scopeFocusRequest?: number;
variant?: "bar" | "icon";
variant?: "bar" | "icon" | "dialog";
};
const MAX_SEARCH_SUGGESTIONS = 4;
const SEARCH_RESULT_LIMIT = 40;
@@ -397,6 +397,7 @@ export function TopbarSearch({
const [selectedMenuIndex, setSelectedMenuIndex] = React.useState(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const dialogInputRef = React.useRef<HTMLInputElement>(null);
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const { cancelDeferredModalOpen, openAfterExit, openNextFrame } =
useDeferredModalOpen();
const {
@@ -515,11 +516,14 @@ export function TopbarSearch({
const openSearchDialog = React.useCallback(
(nextScopeChannelId: string | null = null) => {
if (!isOpen && document.activeElement instanceof HTMLElement) {
returnFocusRef.current = document.activeElement;
}
setScopeChannelId(nextScopeChannelId);
setSelectedMenuIndex(0);
openNextFrame(() => setIsOpen(true));
},
[openNextFrame],
[isOpen, openNextFrame],
);
const handleSearchOpenChange = React.useCallback(
@@ -924,44 +928,46 @@ export function TopbarSearch({
return (
<div className={cn("relative", className)}>
<Dialog open={isOpen} onOpenChange={handleSearchOpenChange}>
<button
aria-label="Search everything"
className={
isIconVariant
? "group/search flex size-6 items-center justify-center rounded p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring"
: "group/search flex h-8 w-full items-center gap-2 rounded-md bg-sidebar-border/35 px-2 text-left text-sm text-sidebar-foreground/55 transition-colors duration-150 ease-out hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-sidebar-ring"
}
data-testid="open-search"
onClick={() => openSearchDialog(null)}
ref={triggerRef}
title="Search everything"
type="button"
>
<Search
{variant !== "dialog" && (
<button
aria-label="Search everything"
className={
isIconVariant
? "h-4 w-4 shrink-0"
: "h-4 w-4 shrink-0 text-sidebar-foreground/45 transition-colors duration-150 ease-out group-hover/search:text-sidebar-foreground/65 group-focus-visible/search:text-sidebar-foreground"
? "group/search flex size-6 items-center justify-center rounded p-1 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring"
: "group/search flex h-8 w-full items-center gap-2 rounded-md bg-sidebar-border/35 px-2 text-left text-sm text-sidebar-foreground/55 transition-colors duration-150 ease-out hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-sidebar-ring"
}
/>
{isIconVariant ? null : (
<>
<span
className={cn(
"min-w-0 flex-1 truncate transition-colors duration-150 ease-out",
query
? "text-sidebar-foreground"
: "text-sidebar-foreground/55",
)}
>
{query || "Search everything"}
</span>
<kbd className="shrink-0 text-2xs text-sidebar-foreground/45">
&#x2318;K
</kbd>
</>
)}
</button>
data-testid="open-search"
onClick={() => openSearchDialog(null)}
ref={triggerRef}
title="Search everything"
type="button"
>
<Search
className={
isIconVariant
? "h-4 w-4 shrink-0"
: "h-4 w-4 shrink-0 text-sidebar-foreground/45 transition-colors duration-150 ease-out group-hover/search:text-sidebar-foreground/65 group-focus-visible/search:text-sidebar-foreground"
}
/>
{isIconVariant ? null : (
<>
<span
className={cn(
"min-w-0 flex-1 truncate transition-colors duration-150 ease-out",
query
? "text-sidebar-foreground"
: "text-sidebar-foreground/55",
)}
>
{query || "Search everything"}
</span>
<kbd className="shrink-0 text-2xs text-sidebar-foreground/45">
&#x2318;K
</kbd>
</>
)}
</button>
)}
<DialogContent
aria-busy={isSearchLoading && visibleSearchableResults.length === 0}
className="mt-[18vh] max-w-2xl self-start gap-0 overflow-hidden rounded-2xl p-0 shadow-2xl"
@@ -972,7 +978,7 @@ export function TopbarSearch({
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus();
(triggerRef.current ?? returnFocusRef.current)?.focus();
}}
showCloseButton={false}
>

View File

@@ -1011,10 +1011,17 @@ test("bubbles have equal corners, unwrapped media, and aligned reply indicators"
).toBeLessThan(1);
const summary = row.getByTestId("message-thread-summary");
await expect(summary).toBeVisible();
await summary.hover();
await expect(row.getByTestId("message-thread-summary-surface")).toHaveCount(
0,
);
await expect(summary).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
const bodyBox = await body.boundingBox();
const summaryBox = await summary.boundingBox();
if (!bodyBox || !summaryBox) throw new Error("Missing message bounds");
expect(Math.abs(bodyBox.x - summaryBox.x)).toBeLessThan(2);
expect(
Math.abs(bodyBox.x + bodyBox.width - summaryBox.x - summaryBox.width),
).toBeLessThan(2);
}
const short = detail
.getByTestId("message-row")
@@ -1028,7 +1035,9 @@ test("bubbles have equal corners, unwrapped media, and aligned reply indicators"
.getByTestId("message-thread-summary")
.boundingBox();
if (!bodyBox || !summaryBox) throw new Error("Missing message bounds");
expect(Math.abs(bodyBox.x - summaryBox.x)).toBeLessThan(2);
expect(
Math.abs(bodyBox.x + bodyBox.width - summaryBox.x - summaryBox.width),
).toBeLessThan(2);
const imageOnly = detail
.locator('[data-message-bubble="incoming"]')
.filter({ has: page.locator("[data-block-media]") });
@@ -2258,3 +2267,136 @@ test("workspace profile panels share the main container's frame and remain resiz
const restored = await boundsOf(main);
expect(restored.x + restored.width / 2).toBeCloseTo(720, 0);
});
test("global search opens with the keyboard across Pulse apps and restores focus", async ({
page,
}) => {
await seed(page);
const apps = page.getByTestId("pulse-app-navigation");
const dialog = page.getByRole("dialog", {
name: "Search everything",
exact: true,
});
for (const name of ["Messages", "Projects", "Agents", "Workflows"]) {
const entry = apps.getByRole("button", { name, exact: true });
await entry.click();
await entry.focus();
await page.keyboard.press("ControlOrMeta+k");
await expect(dialog).toBeVisible();
await expect(page.getByTestId("search-dialog-input")).toBeFocused();
await expect(
dialog.getByText("Browse channels", { exact: true }),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(entry).toBeFocused();
}
await apps.getByRole("button", { name: "Messages", exact: true }).click();
await page
.getByTestId("pulse-combined-list")
.locator('[data-channel-name="alice-tyler"]')
.click();
const composer = page.getByTestId("message-input");
await composer.fill("Keep my draft");
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ControlOrMeta+k");
await expect(dialog).toBeVisible();
await page.getByTestId("search-dialog-input").fill("welcome");
await expect(dialog).toContainText("Welcome to #general");
await waitForAnimations(page);
await dialog.screenshot({
path: "test-results/pulse-prototype/global-search.png",
});
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(composer).toBeFocused();
await expect(composer).toHaveText("Keep my draft");
await expect(page.getByTestId("sidebar-pinned-header")).toHaveCount(0);
});
test("global search results stay in Pulse and open the selected conversation or message", async ({
page,
}) => {
await seed(page);
const apps = page.getByTestId("pulse-app-navigation");
const dialog = page.getByRole("dialog", {
name: "Search everything",
exact: true,
});
const search = async (query: string) => {
await page.keyboard.press("ControlOrMeta+k");
await expect(dialog).toBeVisible();
await page.getByTestId("search-dialog-input").fill(query);
};
const expectContained = async () => {
await expect(dialog).toHaveCount(0);
await expect(page).toHaveURL(/#\/pulse\?/);
await expect(apps).toBeVisible();
await expect(page.getByTestId("pulse-combined-list")).toBeVisible();
await expect(page.getByTestId("sidebar-pinned-header")).toHaveCount(0);
};
await apps.getByRole("button", { name: "Projects", exact: true }).click();
await search("general");
await dialog
.locator('[data-search-section="channels"] .search-result-row')
.filter({ hasText: "general" })
.first()
.click();
await expectContained();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await search("Alice");
await dialog
.locator('[data-search-section="direct-messages"] .search-result-row')
.filter({ hasText: "Alice" })
.first()
.click();
await expectContained();
await expect(page.getByTestId("chat-title")).toHaveText(/Alice/i);
await search("Deep history message #0");
await dialog
.locator('[data-search-section="messages"] .search-result-row')
.filter({ hasText: "Deep history message #0" })
.first()
.click();
await expectContained();
await expect(
page.locator('[data-message-id="mock-deep-history-0"]'),
).toBeVisible();
await expect(
page.locator('[data-message-id="mock-deep-history-0"] mark').first(),
).toBeVisible();
await search("Keep the audience visible");
await dialog
.locator('[data-search-section="messages"] .search-result-row')
.filter({ hasText: "Keep the audience visible" })
.first()
.click();
await expectContained();
await expect(page.getByTestId("message-thread-body")).toContainText(
"Keep the audience visible",
);
await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0);
});
test("Pulse starts without the browser media devices API", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: undefined,
});
});
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await seed(page);
await page
.getByTestId("pulse-combined-list")
.locator('[data-channel-name="alice-tyler"]')
.click();
await expect(page.getByTestId("message-input")).toBeVisible();
await expect(
page.getByText("Something went wrong!", { exact: true }),
).toHaveCount(0);
expect(errors).toEqual([]);
});