mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Keep Pulse search navigation contained and tolerate unavailable media APIs
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
31
desktop/src/app/AppNavigationScope.tsx
Normal file
31
desktop/src/app/AppNavigationScope.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
⌘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">
|
||||
⌘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}
|
||||
>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user