diff --git a/desktop/src/app/AppNavigationScope.tsx b/desktop/src/app/AppNavigationScope.tsx new file mode 100644 index 0000000000..c35f333a95 --- /dev/null +++ b/desktop/src/app/AppNavigationScope.tsx @@ -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 ( + + {children} + + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index cba83062f8..9d1dd5911f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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 ( + + + + ); +} + +function AppShellContent() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); @@ -962,6 +973,25 @@ export function AppShell() { ) : null} )} + {isPulse && !settingsOpen && ( + { + const dm = await openDmMutation.mutateAsync({ + pubkeys: [user.pubkey], + }); + await goChannel(dm.id); + }} + onBrowseChannels={handleOpenBrowseChannels} + onCreateAgent={() => requestOpenCreateAgent()} + /> + )} & { 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"), diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index 8e6ccbbd89..4d87ec6d62 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -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); }; }, []); diff --git a/desktop/src/features/huddle/lib/useAudioDevices.ts b/desktop/src/features/huddle/lib/useAudioDevices.ts index 13347b14b6..352bb1cbc8 100644 --- a/desktop/src/features/huddle/lib/useAudioDevices.ts +++ b/desktop/src/features/huddle/lib/useAudioDevices.ts @@ -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); }; }, []); diff --git a/desktop/src/features/messages/ui/MessageBubbleLayout.tsx b/desktop/src/features/messages/ui/MessageBubbleLayout.tsx index e0bfc65e40..814f2d67fb 100644 --- a/desktop/src/features/messages/ui/MessageBubbleLayout.tsx +++ b/desktop/src/features/messages/ui/MessageBubbleLayout.tsx @@ -126,7 +126,16 @@ export function MessageBubbleLayout({ )}
{extras}
- {footer &&
{footer}
} + {footer && ( +
+ {footer} +
+ )} ); diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index e6ee49d240..9be142e633 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -211,7 +211,10 @@ export function MessageThreadSummaryRow({ + data-testid="open-search" + onClick={() => openSearchDialog(null)} + ref={triggerRef} + title="Search everything" + type="button" + > + + {isIconVariant ? null : ( + <> + + {query || "Search everything"} + + + ⌘K + + + )} + + )} { event.preventDefault(); - triggerRef.current?.focus(); + (triggerRef.current ?? returnFocusRef.current)?.focus(); }} showCloseButton={false} > diff --git a/desktop/tests/e2e/pulse-unified.spec.ts b/desktop/tests/e2e/pulse-unified.spec.ts index 4c0730ea82..55cf1d2673 100644 --- a/desktop/tests/e2e/pulse-unified.spec.ts +++ b/desktop/tests/e2e/pulse-unified.spec.ts @@ -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([]); +});