Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
resolveSlotSound,
} from "@/features/notifications/lib/sound";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import {
usePresenceSession,
usePresenceSubscription,
Expand Down Expand Up @@ -99,10 +100,8 @@ const LazySettingsScreen = React.lazy(async () => {

export function AppShell() {
useWebviewZoomShortcuts();

const workspacesHook = useWorkspaces();
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);

const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
Expand Down Expand Up @@ -142,7 +141,6 @@ export function AppShell() {
)
? locationSearchSection
: DEFAULT_SETTINGS_SECTION;

const startupReady = useDeferredStartup();

const identityQuery = useIdentityQuery();
Expand Down Expand Up @@ -630,12 +628,10 @@ export function AppShell() {
}, []);

const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []);

const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true),
[],
);

React.useLayoutEffect(() => {
if (settingsOpen) {
return;
Expand Down Expand Up @@ -690,13 +686,11 @@ export function AppShell() {
goHome,
settingsOpen,
]);

useSettingsShortcuts({
onClose: handleCloseSettings,
onOpenSettings: handleOpenSettings,
open: settingsOpen,
});

useMarkAsReadShortcuts({
activeChannelId: activeChannel?.id ?? null,
activeChannelLastMessageAt: activeChannel?.lastMessageAt,
Expand Down Expand Up @@ -849,6 +843,9 @@ export function AppShell() {
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
onCreateAgent={() =>
void goAgents().then(requestOpenCreateAgent)
}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}
onCreateChannel={async ({
Expand Down
25 changes: 23 additions & 2 deletions desktop/src/app/AppShellOverlays.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";

import type { Channel } from "@/shared/api/types";
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";

const ChannelBrowserDialog = React.lazy(async () => {
const module = await import("@/features/channels/ui/ChannelBrowserDialog");
Expand Down Expand Up @@ -39,17 +40,37 @@ export function AppShellOverlays({
onDeleteActiveChannel,
onSelectChannel,
}: AppShellOverlaysProps) {
const [visibleBrowseDialogType, setVisibleBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } =
useDeferredModalOpen();

React.useEffect(() => {
if (browseDialogType === null) {
cancelDeferredModalOpen();
setVisibleBrowseDialogType(null);
return;
}

setVisibleBrowseDialogType(null);
openModalNextFrame(() => {
setVisibleBrowseDialogType(browseDialogType);
});
}, [browseDialogType, cancelDeferredModalOpen, openModalNextFrame]);

const renderedBrowseDialogType = visibleBrowseDialogType ?? browseDialogType;

return (
<>
{browseDialogType !== null ? (
<React.Suspense fallback={null}>
<ChannelBrowserDialog
channels={channels}
channelTypeFilter={browseDialogType}
channelTypeFilter={renderedBrowseDialogType ?? browseDialogType}
onJoinChannel={onBrowseChannelJoin}
onOpenChange={onBrowseDialogOpenChange}
onSelectChannel={onSelectChannel}
open={true}
open={visibleBrowseDialogType !== null}
/>
</React.Suspense>
) : null}
Expand Down
30 changes: 30 additions & 0 deletions desktop/src/features/agents/openCreateAgentEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const OPEN_CREATE_AGENT_EVENT = "buzz:open-create-agent";

let pendingOpenCreateAgent = false;

export function requestOpenCreateAgent() {
pendingOpenCreateAgent = true;
window.dispatchEvent(new Event(OPEN_CREATE_AGENT_EVENT));
}

export function consumePendingOpenCreateAgent() {
if (!pendingOpenCreateAgent) {
return false;
}

pendingOpenCreateAgent = false;
return true;
}

export function subscribeOpenCreateAgent(handler: () => void) {
function handleOpenCreateAgent() {
pendingOpenCreateAgent = false;
handler();
}

window.addEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent);

return () => {
window.removeEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent);
};
}
15 changes: 15 additions & 0 deletions desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as React from "react";
import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import {
consumePendingOpenCreateAgent,
subscribeOpenCreateAgent,
} from "@/features/agents/openCreateAgentEvent";
import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog";
import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog";
import { BatchImportDialog } from "./BatchImportDialog";
Expand Down Expand Up @@ -42,6 +47,16 @@ export function AgentsView() {
teamActions.updateTeamMutation.isPending ||
teamActions.deleteTeamMutation.isPending;

React.useEffect(() => {
if (consumePendingOpenCreateAgent()) {
agents.setIsCreateOpen(true);
}

return subscribeOpenCreateAgent(() => {
agents.setIsCreateOpen(true);
});
}, [agents.setIsCreateOpen]);

return (
<>
<div
Expand Down
206 changes: 206 additions & 0 deletions desktop/src/features/search/ui/SearchPromptPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";

const SEARCH_PROMPT_WORDS = [
"everything",
"a channel",
"a message",
"a thread",
"an agent",
] as const;
const SEARCH_PROMPT_ROTATION_MS = 3200;
const SEARCH_PROMPT_EASE = [0.22, 1, 0.36, 1] as const;
const SEARCH_PROMPT_EXIT_EASE = [0.64, 0, 0.78, 0] as const;
const SEARCH_PROMPT_ENTER_DURATION_SECONDS = 0.54;
const SEARCH_PROMPT_EXIT_DURATION_SECONDS = 0.32;
const SEARCH_PROMPT_ENTER_STAGGER_SECONDS = 0.014;
const SEARCH_PROMPT_EXIT_STAGGER_SECONDS = 0.008;
const SEARCH_PROMPT_Y_OFFSET = "0.5rem";
const SEARCH_PROMPT_NEGATIVE_Y_OFFSET = "-0.5rem";
const SEARCH_PROMPT_BLUR = "0.25rem";

const searchPromptPhraseVariants = {
animate: {
transition: {
staggerChildren: SEARCH_PROMPT_ENTER_STAGGER_SECONDS,
},
},
exit: {
transition: {
staggerChildren: SEARCH_PROMPT_EXIT_STAGGER_SECONDS,
},
},
initial: {},
};

const searchPromptCharacterVariants = {
animate: {
filter: "blur(0)",
opacity: 1,
transition: {
duration: SEARCH_PROMPT_ENTER_DURATION_SECONDS,
ease: SEARCH_PROMPT_EASE,
},
y: 0,
},
exit: {
filter: `blur(${SEARCH_PROMPT_BLUR})`,
opacity: 0,
transition: {
duration: SEARCH_PROMPT_EXIT_DURATION_SECONDS,
ease: SEARCH_PROMPT_EXIT_EASE,
},
y: SEARCH_PROMPT_NEGATIVE_Y_OFFSET,
},
initial: {
filter: `blur(${SEARCH_PROMPT_BLUR})`,
opacity: 0,
y: SEARCH_PROMPT_Y_OFFSET,
},
};

function getPromptCharacters(value: string) {
const characterCounts = new Map<string, number>();

return [...value].map((character) => {
const occurrence = characterCounts.get(character) ?? 0;
characterCounts.set(character, occurrence + 1);

return {
character,
key: `${character}-${occurrence}`,
};
});
}

function getPromptEnterTotalSeconds(characterCount: number) {
return (
SEARCH_PROMPT_ENTER_DURATION_SECONDS +
Math.max(0, characterCount - 1) * SEARCH_PROMPT_ENTER_STAGGER_SECONDS
);
}

export function SearchPromptPlaceholder() {
const shouldReduceMotion = useReducedMotion();
const [wordIndex, setWordIndex] = React.useState(0);
const activeWord = SEARCH_PROMPT_WORDS[wordIndex];
const activeCharacters = React.useMemo(
() => getPromptCharacters(activeWord),
[activeWord],
);
const widthAnimationDurationSeconds = getPromptEnterTotalSeconds(
activeCharacters.length,
);
const measureRef = React.useRef<HTMLSpanElement>(null);
const pendingWordWidthRef = React.useRef<number | null>(null);
const [wordWidth, setWordWidth] = React.useState<number | null>(null);

React.useEffect(() => {
if (shouldReduceMotion) {
setWordIndex(0);
return;
}

const intervalId = window.setInterval(() => {
setWordIndex((currentIndex) => {
return (currentIndex + 1) % SEARCH_PROMPT_WORDS.length;
});
}, SEARCH_PROMPT_ROTATION_MS);

return () => window.clearInterval(intervalId);
}, [shouldReduceMotion]);

React.useLayoutEffect(() => {
if (shouldReduceMotion || activeWord.length === 0) {
return;
}

const width = measureRef.current?.getBoundingClientRect().width;
if (typeof width === "number" && Number.isFinite(width)) {
if (wordWidth === null) {
setWordWidth(width);
} else {
pendingWordWidthRef.current = width;
}
}
}, [activeWord, shouldReduceMotion, wordWidth]);

const handleWordExitComplete = React.useCallback(() => {
const nextWidth = pendingWordWidthRef.current;
if (nextWidth === null) {
return;
}

pendingWordWidthRef.current = null;
setWordWidth(nextWidth);
}, []);

if (shouldReduceMotion) {
return (
<span
aria-hidden="true"
className="text-muted-foreground"
data-testid="search-placeholder"
>
Search for {activeWord}
</span>
);
}

return (
<span
aria-hidden="true"
className="pointer-events-none inline-flex min-w-0 items-baseline text-muted-foreground"
data-active-search-prompt={activeWord}
data-search-prompt-options={SEARCH_PROMPT_WORDS.join(",")}
data-testid="search-placeholder"
>
<span>Search for&nbsp;</span>
<span
className="relative inline-block overflow-visible whitespace-nowrap align-baseline leading-[inherit] motion-safe:transition-[width] motion-safe:ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
data-width-animation-duration-ms={Math.round(
widthAnimationDurationSeconds * 1000,
)}
style={{
transitionDuration: `${widthAnimationDurationSeconds}s`,
...(wordWidth === null ? {} : { width: wordWidth }),
}}
>
<span className="sr-only">everything</span>
<span
aria-hidden="true"
className="pointer-events-none invisible inline-block whitespace-nowrap leading-[inherit]"
ref={measureRef}
>
{activeWord}
</span>
<AnimatePresence
initial={false}
mode="wait"
onExitComplete={handleWordExitComplete}
>
<motion.span
aria-hidden="true"
animate="animate"
className="absolute inset-x-0 top-0 inline-block whitespace-nowrap leading-[inherit] [transform-style:preserve-3d]"
exit="exit"
initial="initial"
key={activeWord}
variants={searchPromptPhraseVariants}
>
{activeCharacters.map(({ character, key }) => (
<motion.span
className="inline-block whitespace-pre [backface-visibility:hidden] [transform-origin:50%_55%] will-change-[transform,opacity,filter]"
data-testid="search-placeholder-character"
key={`${activeWord}-${key}`}
variants={searchPromptCharacterVariants}
>
{character}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</span>
</span>
);
}
Loading
Loading