diff --git a/src/main/demo/fake-inbox.ts b/src/main/demo/fake-inbox.ts index 9ec2cc70..057f7966 100644 --- a/src/main/demo/fake-inbox.ts +++ b/src/main/demo/fake-inbox.ts @@ -47,6 +47,7 @@ const RICH_HTML_EMAIL_BODY = `

You can view the full dashboard here: Q3 Report Dashboard

+

Backup link: Dashboard mirror. Questions? Email analytics.

Best regards,

diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9fff4a44..bcac7385 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -15,6 +15,7 @@ import { SettingsPanel } from "./components/SettingsPanel"; import { SetupWizard } from "./components/SetupWizard"; import { SearchBar } from "./components/SearchBar"; import { CommandPalette } from "./components/CommandPalette"; +import { OpenLinksAttachmentsPalette } from "./components/OpenLinksAttachmentsPalette"; import { AgentCommandPalette } from "./components/AgentCommandPalette"; import { AgentsSidebar } from "./components/AgentsSidebar"; import { ShortcutHelp } from "./components/ShortcutHelp"; @@ -644,6 +645,7 @@ export default function App() { const composeState = useAppStore((s) => s.composeState); const isSearchOpen = useAppStore((s) => s.isSearchOpen); const isCommandPaletteOpen = useAppStore((s) => s.isCommandPaletteOpen); + const isOpenLinksAttachmentsOpen = useAppStore((s) => s.isOpenLinksAttachmentsOpen); const isFindBarOpen = useAppStore((s) => s.isFindBarOpen); const isAgentPaletteOpen = useAppStore((s) => s.isAgentPaletteOpen); const isAgentsSidebarOpen = useAppStore((s) => s.isAgentsSidebarOpen); @@ -676,6 +678,7 @@ export default function App() { const openSearch = useAppStore((s) => s.openSearch); const closeSearch = useAppStore((s) => s.closeSearch); const closeCommandPalette = useAppStore((s) => s.closeCommandPalette); + const closeLinksAttachments = useAppStore((s) => s.closeLinksAttachments); const setAgentPaletteOpen = useAppStore((s) => s.setAgentPaletteOpen); const setViewMode = useAppStore((s) => s.setViewMode); const _clearActiveSearch = useAppStore((s) => s.clearActiveSearch); @@ -2299,6 +2302,12 @@ export default function App() { }} /> + {/* Open Links & Attachments Palette */} + + {/* Agent Command Palette */} (null); - const listRef = useRef(null); const { selectedAgentIds, @@ -234,28 +238,17 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp setAvailableProviders, ]); - // Reset state when opened/closed + const { selectedIndex, setSelectedIndex, inputRef, listRef, moveSelection } = usePaletteSelection( + { isOpen, query, itemCount: filteredActions.length }, + ); + + // Reset query when opened useEffect(() => { if (isOpen) { setQuery(""); - setSelectedIndex(0); - requestAnimationFrame(() => inputRef.current?.focus()); } }, [isOpen]); - useEffect(() => { - setSelectedIndex(0); - }, [query]); - - // Scroll selected item into view - useEffect(() => { - if (!listRef.current) return; - const el = listRef.current.querySelector(`[data-index="${selectedIndex}"]`); - if (el) { - el.scrollIntoView({ block: "nearest" }); - } - }, [selectedIndex]); - const handleSubmit = useCallback( async (prompt: string) => { if (!prompt.trim()) return; @@ -359,11 +352,11 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp break; case "ArrowDown": e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, filteredActions.length - 1)); + moveSelection(1); break; case "ArrowUp": e.preventDefault(); - setSelectedIndex((i) => Math.max(i - 1, 0)); + moveSelection(-1); break; case "Enter": e.preventDefault(); @@ -376,20 +369,15 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp break; } }, - [filteredActions, selectedIndex, query, handleSubmit, onClose], + [filteredActions, selectedIndex, query, handleSubmit, moveSelection, onClose], ); if (!isOpen) return null; return ( -
- {/* Backdrop */} -
- - {/* Palette panel */} -
- {/* Input */} -
+ + - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={ - hasEmail - ? "Ask agent about this email..." - : hasDraft - ? "Ask agent about this draft..." - : "Ask agent anything..." - } - className="flex-1 text-base outline-none placeholder-gray-400 dark:text-gray-100 dark:placeholder-gray-500 bg-transparent" - /> - - esc - -
- - {/* Agent selector + context indicator */} -
- {availableProviders.length > 0 ? ( - availableProviders.map((p) => { - const isSelected = selectedAgentIds.includes(p.id); + } + inputRef={inputRef} + query={query} + onQueryChange={setQuery} + onKeyDown={handleKeyDown} + placeholder={ + hasEmail + ? "Ask agent about this email..." + : hasDraft + ? "Ask agent about this draft..." + : "Ask agent anything..." + } + /> + + {/* Agent selector + context indicator */} +
+ {availableProviders.length > 0 ? ( + availableProviders.map((p) => { + const isSelected = selectedAgentIds.includes(p.id); + return ( + + ); + }) + ) : ( + No agents available + )} + + {selectedEmail ? ( + <> + | + + {selectedEmail.subject} + + + ) : selectedDraft ? ( + <> + | + + Draft: {selectedDraft.subject || "(no subject)"} + + + ) : ( + <> + | + No email context + + )} +
+ + {/* Quick actions */} + + {filteredActions.length === 0 ? ( +
+ No matching actions. Press Enter to send custom prompt. +
+ ) : ( + <> + {suggestedActions.length > 0 && !query.trim() && ( +
+ Suggested +
+ )} + {filteredActions.map((action, idx) => { + const isSelected = idx === selectedIndex; + const isSuggested = suggestedActions.some((s) => s.id === action.id); + + // Show "Quick Actions" header before the first non-suggested action + const showQuickHeader = + !query.trim() && + !isSuggested && + (idx === 0 || suggestedActions.some((s) => s.id === filteredActions[idx - 1]?.id)); + return ( - - ); - }) - ) : ( - No agents available - )} - - {selectedEmail ? ( - <> - | - - {selectedEmail.subject} - - - ) : selectedDraft ? ( - <> - | - - Draft: {selectedDraft.subject || "(no subject)"} - - - ) : ( - <> - | - No email context - - )} -
- - {/* Quick actions */} -
- {filteredActions.length === 0 ? ( -
- No matching actions. Press Enter to send custom prompt. -
- ) : ( - <> - {suggestedActions.length > 0 && !query.trim() && ( -
- Suggested -
- )} - {filteredActions.map((action, idx) => { - const isSelected = idx === selectedIndex; - const isSuggested = suggestedActions.some((s) => s.id === action.id); - - // Show "Quick Actions" header before the first non-suggested action - const showQuickHeader = - !query.trim() && - !isSuggested && - (idx === 0 || - suggestedActions.some((s) => s.id === filteredActions[idx - 1]?.id)); - - return ( -
- {showQuickHeader && ( -
- Quick Actions -
- )} - -
- ); - })} - - )} -
- - {/* Footer */} -
- - ↑↓{" "} - navigate - - - Enter run - - - Esc close - -
-
-
+ + + {action.label} + {isSuggested && ( + + suggested + + )} + +
+ ); + })} + + )} + + + + ); } diff --git a/src/renderer/components/AgentPanel.tsx b/src/renderer/components/AgentPanel.tsx index d4506175..bd899c26 100644 --- a/src/renderer/components/AgentPanel.tsx +++ b/src/renderer/components/AgentPanel.tsx @@ -6,6 +6,7 @@ import type { ScopedAgentEvent, AgentTaskState, AgentTaskInfo } from "../../shar import { DEFAULT_BACKGROUND_AGENT_PROVIDER } from "../../shared/types"; import { AgentConfirmationDialog } from "./AgentConfirmationDialog"; import { trackEvent } from "../services/posthog"; +import { formatPlatformShortcut } from "../utils/platform"; function StatusChip({ status }: { status: AgentTaskState }) { const config: Record = { @@ -860,7 +861,7 @@ export const AgentTabContent = memo(function AgentTabContent({ emailId }: { emai if (!task) { return (
- No active agent task. Press Cmd+J to start. + No active agent task. Press {formatPlatformShortcut("J")} to start. {hasPendingDraft && ( - ); - })} + } + inputRef={inputRef} + query={query} + onQueryChange={setQuery} + onKeyDown={handleKeyDown} + placeholder="Type a command..." + /> + + + {groupedActions.length === 0 ? ( +
+ No matching commands +
+ ) : ( + groupedActions.map(({ category, actions: catActions }) => ( +
+
+ {category}
- )) - )} -
- - {/* Footer */} -
- - ↑↓{" "} - navigate - - - Enter execute - - - Esc close - -
-
-
+ {catActions.map((action) => { + const idx = flatIndex++; + const isSelected = idx === selectedIndex; + return ( + + ); + })} + + )) + )} + + + + ); } diff --git a/src/renderer/components/ComposeToolbar.tsx b/src/renderer/components/ComposeToolbar.tsx index 859d907f..93685686 100644 --- a/src/renderer/components/ComposeToolbar.tsx +++ b/src/renderer/components/ComposeToolbar.tsx @@ -1,5 +1,6 @@ import { ScheduleSendButton } from "./ScheduleSendButton"; import type { Signature } from "../../shared/types"; +import { formatPlatformShortcut } from "../utils/platform"; interface ComposeToolbarProps { onSend: () => void; @@ -53,7 +54,9 @@ export function ComposeToolbar({ /> - Cmd+Enter to send + + {formatPlatformShortcut("Enter")} to send + {availableSignatures.length > 0 && ( onQueryChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + className="flex-1 text-base outline-none placeholder-gray-400 dark:text-gray-100 dark:placeholder-gray-500 bg-transparent" + /> + {trailing} + + esc + + + ); +} + +/** Scrollable results container; items must carry data-index attributes. */ +export function PaletteResults({ + listRef, + children, +}: { + listRef: RefObject; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** The ↑↓ / Enter / Esc hint bar. */ +export function PaletteFooter({ enterLabel }: { enterLabel: string }) { + return ( +
+ + ↑↓{" "} + navigate + + + Enter {enterLabel} + + + Esc close + +
+ ); +} diff --git a/src/renderer/components/UndoActionToast.tsx b/src/renderer/components/UndoActionToast.tsx index 4a2b0493..6b56e0fd 100644 --- a/src/renderer/components/UndoActionToast.tsx +++ b/src/renderer/components/UndoActionToast.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useCallback } from "react"; import { useAppStore, type UndoActionItem } from "../store"; +import { formatPlatformShortcut } from "../utils/platform"; // Map of item ID -> cancel function for keyboard shortcut access const cancelHandlers = new Map void>(); @@ -346,7 +347,7 @@ function UndoActionToastItem({ item }: { item: UndoActionItem }) { diff --git a/src/renderer/components/UndoSendToast.tsx b/src/renderer/components/UndoSendToast.tsx index c9e784be..2a6799bc 100644 --- a/src/renderer/components/UndoSendToast.tsx +++ b/src/renderer/components/UndoSendToast.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useAppStore, type UndoSendItem } from "../store"; +import { formatPlatformShortcut } from "../utils/platform"; // Map of item ID → cancel function, so the parent (or keyboard shortcut) can // trigger a clean undo on any queued item without race conditions. @@ -156,7 +157,7 @@ function UndoSendToastItem({ item }: { item: UndoSendItem }) { diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 7403f5ca..7765e793 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -5,6 +5,7 @@ import { markNavigationActive } from "./useSyncBuffer"; import { mergeAndThreadSearchResults } from "../utils/searchResults"; import { draftMatchesSplit } from "../utils/split-conditions"; import { trackEvent } from "../services/posthog"; +import { formatPlatformShortcut, isMacPlatform } from "../utils/platform"; declare global { interface Window { @@ -48,12 +49,25 @@ function isInputFocused(): boolean { ); } +// Cmd/Ctrl combos that are app-level shortcuts and must be forwarded from the +// email-body iframe to the parent window (see EmailDetail's iframeKeydownHandler): +// agent palette (j), command palette (k), settings (,), find-in-page (f), +// open links & attachments (o). +export const APP_SHORTCUT_MODIFIER_KEYS = new Set(["j", "k", ",", "f", "o"]); + // Read current keyboard mode directly from store (no closure dependency) function getKeyboardMode(): KeyboardMode { - const { composeState, isSearchOpen, isCommandPaletteOpen, isAgentPaletteOpen } = - useAppStore.getState(); + const { + composeState, + isSearchOpen, + isCommandPaletteOpen, + isAgentPaletteOpen, + isOpenLinksAttachmentsOpen, + } = useAppStore.getState(); if (composeState?.isOpen) return "compose"; - if (isSearchOpen || isCommandPaletteOpen || isAgentPaletteOpen) return "search"; + if (isSearchOpen || isCommandPaletteOpen || isAgentPaletteOpen || isOpenLinksAttachmentsOpen) { + return "search"; + } return "normal"; } @@ -172,6 +186,11 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) } return; } + if (state.isOpenLinksAttachmentsOpen) { + e.preventDefault(); + state.closeLinksAttachments(); + return; + } if (mode === "compose") { // New compose: always let the compose component handle Esc (it saves the draft) // Reply/forward compose (InlineReply): only defer when input is focused @@ -242,13 +261,22 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) // Cmd+F (macOS) / Ctrl+F (Windows/Linux) for find-in-page // Don't intercept Ctrl+F on macOS — it's Emacs cursor-forward in text inputs - const isMac = navigator.platform.startsWith("Mac"); + const isMac = isMacPlatform(); if (e.key === "f" && (isMac ? e.metaKey : e.ctrlKey)) { e.preventDefault(); openAndFocusFindBar(); return; } + // Cmd+O (macOS) / Ctrl+O (Windows/Linux): open links and attachments for + // the currently focused email. Do not intercept Ctrl+O on macOS, where it + // is the native Emacs-style open-line binding in text inputs. + if ((isMac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === "o") { + e.preventDefault(); + state.openLinksAttachments(); + return; + } + // In compose mode, only handle Cmd+Enter for send — except when the // editor isn't focused (e.g. auto-opened draft without focus), where // Enter should focus the editor and "b" should switch sidebar tabs. @@ -1178,6 +1206,7 @@ export function getKeyboardShortcuts(bindings: "superhuman" | "gmail") { ...(isGmail ? [{ key: "Shift+I", description: "Mark as read" }] : []), { key: "s", description: "Star / unstar" }, { key: "h", description: "Snooze" }, + { key: formatPlatformShortcut("O"), description: "Open links and attachments" }, ...(isGmail ? [ { key: "z", description: "Undo last action" }, @@ -1186,7 +1215,7 @@ export function getKeyboardShortcuts(bindings: "superhuman" | "gmail") { : []), { key: "x", description: "Select / deselect thread" }, { key: "Shift+J/K", description: "Extend selection down/up" }, - { key: "Cmd+A", description: "Select all threads" }, + { key: formatPlatformShortcut("A"), description: "Select all threads" }, ], compose: [ { key: "c", description: "Compose new email" }, @@ -1196,13 +1225,13 @@ export function getKeyboardShortcuts(bindings: "superhuman" | "gmail") { ], search: [ { key: "/", description: "Open search" }, - { key: "Cmd+F", description: "Find in page" }, - { key: "Cmd+K", description: "Command palette" }, - { key: "Cmd+J", description: "Agent action palette" }, + { key: formatPlatformShortcut("F"), description: "Find in page" }, + { key: formatPlatformShortcut("K"), description: "Command palette" }, + { key: formatPlatformShortcut("J"), description: "Agent action palette" }, ], other: [ { key: "b", description: "Switch sidebar tab" }, - { key: "Cmd+,", description: "Settings" }, + { key: formatPlatformShortcut(","), description: "Settings" }, { key: "?", description: "Show shortcuts" }, ], }; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 315aa23c..0e45f4d2 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -230,6 +230,7 @@ interface AppState { // Command palette state isCommandPaletteOpen: boolean; + isOpenLinksAttachmentsOpen: boolean; // Search state isSearchOpen: boolean; @@ -393,6 +394,8 @@ interface AppState { // Command palette actions openCommandPalette: () => void; closeCommandPalette: () => void; + openLinksAttachments: () => void; + closeLinksAttachments: () => void; // Find-in-page isFindBarOpen: boolean; @@ -596,6 +599,7 @@ export const useAppStore = create((set, get) => ({ // Command palette state isCommandPaletteOpen: false, + isOpenLinksAttachmentsOpen: false, // Find-in-page state isFindBarOpen: false, @@ -1078,6 +1082,8 @@ export const useAppStore = create((set, get) => ({ // Command palette actions openCommandPalette: () => set({ isCommandPaletteOpen: true }), closeCommandPalette: () => set({ isCommandPaletteOpen: false }), + openLinksAttachments: () => set({ isOpenLinksAttachmentsOpen: true }), + closeLinksAttachments: () => set({ isOpenLinksAttachmentsOpen: false }), // Find-in-page actions openFindBar: () => set({ isFindBarOpen: true }), diff --git a/src/renderer/utils/attachments.ts b/src/renderer/utils/attachments.ts new file mode 100644 index 00000000..32be4562 --- /dev/null +++ b/src/renderer/utils/attachments.ts @@ -0,0 +1,21 @@ +import type { AttachmentMeta } from "../../shared/types"; + +export type AttachmentWithId = AttachmentMeta & { attachmentId: string }; + +export function hasAttachmentId(attachment: AttachmentMeta): attachment is AttachmentWithId { + return typeof attachment.attachmentId === "string" && attachment.attachmentId.length > 0; +} + +export function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + + const units = ["B", "KB", "MB", "GB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const size = bytes / Math.pow(1024, i); + + return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +export function isPreviewable(mimeType: string): boolean { + return mimeType.startsWith("image/") || mimeType === "application/pdf"; +} diff --git a/src/renderer/utils/openables.ts b/src/renderer/utils/openables.ts new file mode 100644 index 00000000..c828272a --- /dev/null +++ b/src/renderer/utils/openables.ts @@ -0,0 +1,164 @@ +import type { AttachmentMeta, DashboardEmail } from "../../shared/types"; +import { type AttachmentWithId, formatFileSize, hasAttachmentId } from "./attachments"; + +export type OpenableLink = { + kind: "link"; + id: string; + label: string; + metadata: string; + url: string; +}; + +export type OpenableAttachment = { + kind: "attachment"; + id: string; + label: string; + metadata: string; + attachment: AttachmentWithId; +}; + +export type OpenableItem = OpenableLink | OpenableAttachment; + +export const MAX_RENDERED_OPENABLE_ITEMS = 100; + +export function displayUrl(url: URL): string { + const path = url.pathname === "/" ? "" : url.pathname; + const suffix = url.search || url.hash ? `${url.search}${url.hash}` : ""; + return `${url.hostname}${path}${suffix}`; +} + +function normalizeLabel(label: string, fallback: string): string { + const cleaned = label.replace(/\s+/g, " ").trim(); + return cleaned || fallback; +} + +function trimUrlCandidate(candidate: string): string { + let url = candidate; + while (url.length > 0) { + const last = url[url.length - 1]; + if (",.;!?".includes(last)) { + url = url.slice(0, -1); + continue; + } + // Keep a trailing ")" that closes a "(" inside the URL, e.g. + // https://en.wikipedia.org/wiki/Rust_(programming_language); only trim + // parens that are surrounding punctuation. + if (last === ")") { + const opens = url.split("(").length - 1; + const closes = url.split(")").length - 1; + if (closes > opens) { + url = url.slice(0, -1); + continue; + } + } + break; + } + return url; +} + +export function extractLinks(body: string): OpenableLink[] { + if (!body.trim()) return []; + + const doc = new DOMParser().parseFromString(body, "text/html"); + const anchors = Array.from(doc.querySelectorAll("a[href]")); + const seen = new Set(); + const links: OpenableLink[] = []; + + const addLink = (rawHref: string, labelText: string) => { + if (!rawHref) return; + + const href = rawHref.trim(); + if (!href) return; + + const absoluteHref = href.startsWith("//") ? `https:${href}` : href; + if (!/^https?:\/\//i.test(absoluteHref)) return; + + let url: URL; + try { + url = new URL(absoluteHref); + } catch { + return; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return; + if (seen.has(url.href)) return; + seen.add(url.href); + + links.push({ + kind: "link", + id: `link:${url.href}`, + label: normalizeLabel(labelText, displayUrl(url)), + metadata: displayUrl(url), + url: url.href, + }); + }; + + for (const anchor of anchors) { + const rawHref = anchor.getAttribute("href"); + if (!rawHref) continue; + + const title = anchor.getAttribute("title") ?? ""; + const ariaLabel = anchor.getAttribute("aria-label") ?? ""; + const textLabel = normalizeLabel(anchor.textContent ?? "", ""); + addLink(rawHref, textLabel || title || ariaLabel); + } + + const textNodes: string[] = []; + if (doc.body) { + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const parent = node.parentElement; + if (parent?.closest("a, script, style, template, noscript")) continue; + textNodes.push(node.textContent ?? ""); + } + } else { + textNodes.push(body); + } + + // Scan each visible text node independently. Flattening the whole body with + // textContent erases
and block boundaries, which can join a URL to the + // next label or link and turn the combined text into a bogus valid URL. + for (const text of textNodes) { + const urlMatches = text.matchAll(/https?:\/\/[^\s<>"']+/gi); + for (const match of urlMatches) { + const href = trimUrlCandidate(match[0]); + addLink(href, href); + } + } + + return links; +} + +export function attachmentMetadata(attachment: AttachmentMeta): string { + const parts = [formatFileSize(attachment.size)]; + if (attachment.mimeType) parts.push(attachment.mimeType); + return parts.join(" - "); +} + +export function buildOpenables(email: DashboardEmail | null): OpenableItem[] { + if (!email) return []; + + const links = extractLinks(email.body ?? ""); + const attachments: OpenableAttachment[] = (email.attachments ?? []) + .filter(hasAttachmentId) + .map((attachment) => ({ + kind: "attachment", + id: `attachment:${attachment.id}`, + label: attachment.filename, + metadata: attachmentMetadata(attachment), + attachment, + })); + + return [...links, ...attachments]; +} + +export function itemMatches(item: OpenableItem, query: string): boolean { + const tokens = query.toLowerCase().trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return true; + + const haystack = [item.label, item.metadata, item.kind === "link" ? item.url : ""] + .join(" ") + .toLowerCase(); + + return tokens.every((token) => haystack.includes(token)); +} diff --git a/src/renderer/utils/platform.ts b/src/renderer/utils/platform.ts new file mode 100644 index 00000000..593e966a --- /dev/null +++ b/src/renderer/utils/platform.ts @@ -0,0 +1,12 @@ +export function isMacPlatform( + platform = typeof navigator === "undefined" ? "" : navigator.platform, +) { + return platform.toLowerCase().includes("mac"); +} + +export function formatPlatformShortcut( + key: string | number, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): string { + return isMacPlatform(platform) ? `⌘${key}` : `Ctrl+${key}`; +} diff --git a/tests/e2e/open-links-attachments.spec.ts b/tests/e2e/open-links-attachments.spec.ts new file mode 100644 index 00000000..c996b534 --- /dev/null +++ b/tests/e2e/open-links-attachments.spec.ts @@ -0,0 +1,448 @@ +import { test, expect, type ElectronApplication, type Page } from "@playwright/test"; +import { closeApp, launchElectronApp } from "./launch-helpers"; + +type TestEmail = { + id: string; + threadId: string; + subject: string; + body?: string; +}; + +type TestStore = { + getState: () => { + emails: TestEmail[]; + isOpenLinksAttachmentsOpen: boolean; + }; + setState: (patch: Record) => void; +}; + +test.describe("Open Links & Attachments palette", () => { + test.describe.configure({ mode: "serial" }); + + let electronApp: ElectronApplication; + let page: Page; + + test.beforeAll(async ({}, testInfo) => { + const result = await launchElectronApp({ workerIndex: testInfo.workerIndex }); + electronApp = result.app; + page = result.page; + }); + + test.afterAll(async () => { + if (electronApp) { + await closeApp(electronApp); + } + }); + + test("Cmd+O opens current-email links and attachments in a searchable picker", async () => { + const reportEmail = page.locator("button").filter({ hasText: "Q3 Quarterly Report" }).first(); + await expect(reportEmail).toBeVisible({ timeout: 15000 }); + await reportEmail.click(); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + const input = palette.locator('input[placeholder="Open Links & Attachments..."]'); + await expect(input).toBeVisible({ timeout: 3000 }); + await expect(palette.getByText("Links", { exact: true })).toBeVisible(); + await expect(palette.getByText("Attachments", { exact: true })).toBeVisible(); + await expect(palette.getByText("Q3 Report Dashboard")).toBeVisible(); + await expect(palette.getByText("dashboard.example.com/q3-report")).toBeVisible(); + await expect(palette.getByText("Dashboard mirror", { exact: true })).toBeHidden(); + await expect(palette.getByText("Email analytics", { exact: true })).toBeHidden(); + await expect(palette.getByText("Q3_Report_2025.pdf")).toBeVisible(); + await expect(palette.getByText("Q3_Metrics.xlsx")).toBeVisible(); + + await input.fill("metrics"); + await expect(palette.getByText("Q3_Metrics.xlsx")).toBeVisible(); + await expect(palette.getByText("Q3_Report_2025.pdf")).toBeHidden(); + + await input.fill("q3 pdf"); + await expect(palette.getByText("Q3_Report_2025.pdf")).toBeVisible(); + await expect(palette.getByText("Q3_Metrics.xlsx")).toBeHidden(); + + await page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + }); + + test("Ctrl+O remains native in text inputs on macOS", async () => { + const result = await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return null; + + store.setState({ isOpenLinksAttachmentsOpen: false }); + + const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + + const textarea = document.createElement("textarea"); + document.body.appendChild(textarea); + textarea.focus(); + + try { + const event = new KeyboardEvent("keydown", { + key: "o", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + textarea.dispatchEvent(event); + + return { + defaultPrevented: event.defaultPrevented, + paletteOpen: store.getState().isOpenLinksAttachmentsOpen, + }; + } finally { + textarea.remove(); + if (originalPlatform) { + Object.defineProperty(navigator, "platform", originalPlatform); + } else { + Reflect.deleteProperty(navigator, "platform"); + } + } + }); + + expect(result).toEqual({ defaultPrevented: false, paletteOpen: false }); + }); + + test("Escape closes attachment preview without closing the picker", async () => { + const selectedReport = await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return false; + + const state = store.getState(); + const reportEmail = state.emails.find((email) => + email.subject.includes("Q3 Quarterly Report"), + ); + if (!reportEmail) return false; + + store.setState({ + selectedEmailId: reportEmail.id, + selectedThreadId: reportEmail.threadId, + focusedThreadEmailId: null, + viewMode: "split", + }); + return true; + }); + expect(selectedReport).toBe(true); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + await expect(palette).toBeVisible({ timeout: 3000 }); + await palette.getByText("Q3_Report_2025.pdf", { exact: true }).click(); + + const previewHeading = page.getByRole("heading", { name: "Q3_Report_2025.pdf" }); + await expect(previewHeading).toBeVisible({ timeout: 3000 }); + + await page.keyboard.press("Escape"); + await expect(previewHeading).toBeHidden(); + await expect(palette).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); + }); + + test("Cmd+O includes bare URLs from plain text email bodies", async () => { + await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return; + + const state = store.getState(); + const incidentEmail = { + id: "e2e-openables-incident-link", + threadId: "thread-e2e-openables-incident-link", + subject: "URGENT: Production issue affecting checkout flow", + from: "Incident ", + to: "Test ", + date: new Date().toISOString(), + body: `INCIDENT ALERT + +Severity: P1 +Status: Investigating + +Slack: #incident-checkout-012 +Zoom: https://zoom.us/j/123456789`, + attachments: [], + }; + + store.setState({ + emails: [...state.emails.filter((email) => email.id !== incidentEmail.id), incidentEmail], + selectedEmailId: incidentEmail.id, + selectedThreadId: incidentEmail.threadId, + focusedThreadEmailId: null, + viewMode: "split", + }); + }); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + await expect(palette).toBeVisible({ timeout: 3000 }); + await expect(palette.getByText("Links", { exact: true })).toBeVisible(); + await expect(palette.getByText("zoom.us/j/123456789", { exact: true })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); + }); + + test("Cmd+O does not join URLs across HTML element boundaries", async () => { + await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return; + + const state = store.getState(); + const greenhouseUrl = "https://job-boards.greenhouse.io/anthropic/jobs/5195866008"; + const syntheticEmail = { + id: "e2e-openables-adjacent-html-links", + threadId: "thread-e2e-openables-adjacent-html-links", + subject: "Adjacent HTML links", + from: "Jobs ", + to: "Test ", + date: new Date().toISOString(), + body: `${greenhouseUrl}

--------------------

Source
${greenhouseUrl}

--------------------

❤️`, + attachments: [], + }; + + store.setState({ + emails: [...state.emails.filter((email) => email.id !== syntheticEmail.id), syntheticEmail], + selectedEmailId: syntheticEmail.id, + selectedThreadId: syntheticEmail.threadId, + focusedThreadEmailId: null, + viewMode: "split", + }); + }); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + const input = palette.locator('input[placeholder="Open Links & Attachments..."]'); + await expect(input).toBeVisible({ timeout: 3000 }); + await input.fill("5195866008"); + await expect(palette.locator("[data-index]")).toHaveCount(1); + await expect( + palette.getByText("job-boards.greenhouse.io/anthropic/jobs/5195866008", { exact: true }), + ).toBeVisible(); + await expect(palette.getByText(/Sourcehttps:\/\/job-boards/)).toBeHidden(); + + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); + }); + + test("Cmd+O preserves anchor URL punctuation and trims bare URL punctuation", async () => { + await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return; + + const state = store.getState(); + const syntheticEmail = { + id: "e2e-openables-punctuation-links", + threadId: "thread-e2e-openables-punctuation-links", + subject: "Punctuation links", + from: "Links ", + to: "Test ", + date: new Date().toISOString(), + body: ` +

Rust reference

+

Bare runbook: https://docs.example.com/runbook.

+

Bare wiki link https://en.wikipedia.org/wiki/Vim_(text_editor) mid-sentence.

+

Wrapped link (see https://docs.example.com/guide) in parens.

+ `, + attachments: [], + }; + + store.setState({ + emails: [...state.emails.filter((email) => email.id !== syntheticEmail.id), syntheticEmail], + selectedEmailId: syntheticEmail.id, + selectedThreadId: syntheticEmail.threadId, + focusedThreadEmailId: null, + viewMode: "split", + }); + }); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + await expect(palette).toBeVisible({ timeout: 3000 }); + await expect( + palette.getByText("en.wikipedia.org/wiki/Rust_(programming_language)", { exact: true }), + ).toBeVisible(); + await expect(palette.getByText("docs.example.com/runbook", { exact: true })).toBeVisible(); + await expect(palette.getByText("docs.example.com/runbook.", { exact: true })).toBeHidden(); + // Balanced parens in a bare URL are part of the URL and must survive trimming + await expect( + palette.getByText("en.wikipedia.org/wiki/Vim_(text_editor)", { exact: true }), + ).toBeVisible(); + await expect( + palette.getByText("en.wikipedia.org/wiki/Vim_(text_editor", { exact: true }), + ).toBeHidden(); + // A paren that only wraps the URL is surrounding punctuation and is trimmed + await expect(palette.getByText("docs.example.com/guide", { exact: true })).toBeVisible(); + await expect(palette.getByText("docs.example.com/guide)", { exact: true })).toBeHidden(); + + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); + }); + + test("Cmd+O handles URL extraction edge cases", async () => { + await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return; + + const state = store.getState(); + const syntheticEmail = { + id: "e2e-openables-url-extraction-cases", + threadId: "thread-e2e-openables-url-extraction-cases", + subject: "URL extraction cases", + from: "Links ", + to: "Test ", + date: new Date().toISOString(), + body: ` +

Protocol relative CDN

+

Duplicate target

+

Same target again: https://dup.example.com/path?x=1#top

+

Email us

+

Call us

+

+

+

Unsupported bare domain: www.example.com/no-protocol

+

Query one

+

Query two

+ `, + attachments: [], + }; + + store.setState({ + emails: [...state.emails.filter((email) => email.id !== syntheticEmail.id), syntheticEmail], + selectedEmailId: syntheticEmail.id, + selectedThreadId: syntheticEmail.threadId, + focusedThreadEmailId: null, + viewMode: "split", + }); + }); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + await expect(palette).toBeVisible({ timeout: 3000 }); + await expect(palette.getByText("Protocol relative CDN")).toBeVisible(); + await expect(palette.getByText("cdn.example.com/asset", { exact: true })).toBeVisible(); + await expect(palette.getByText("Duplicate target")).toBeVisible(); + await expect(palette.getByText("dup.example.com/path?x=1#top", { exact: true })).toHaveCount(1); + await expect(palette.getByText("Email us", { exact: true })).toBeHidden(); + await expect(palette.getByText("Call us", { exact: true })).toBeHidden(); + await expect(palette.getByText("www.example.com/no-protocol", { exact: true })).toBeHidden(); + await expect(palette.getByText("Title fallback", { exact: true })).toBeVisible(); + await expect(palette.getByText("Aria fallback", { exact: true })).toBeVisible(); + await expect(palette.getByText("same.example.com/path?one=1", { exact: true })).toBeVisible(); + await expect( + palette.getByText("same.example.com/path?two=2#section", { exact: true }), + ).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); + }); + + test("Cmd+O ignores stale focused-thread email outside full view", async () => { + await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return; + + const state = store.getState(); + const selectedEmail = { + id: "e2e-openables-selected-email", + threadId: "thread-e2e-openables-selected", + subject: "Selected split email", + from: "Selected ", + to: "Test ", + date: new Date().toISOString(), + body: `Current split link`, + attachments: [], + }; + const staleFocusedEmail = { + id: "e2e-openables-stale-focused-email", + threadId: "thread-e2e-openables-stale", + subject: "Stale focused email", + from: "Stale ", + to: "Test ", + date: new Date().toISOString(), + body: `Zoom: https://zoom.us/j/123456789`, + attachments: [], + }; + + store.setState({ + emails: [ + ...state.emails.filter( + (email) => email.id !== selectedEmail.id && email.id !== staleFocusedEmail.id, + ), + selectedEmail, + staleFocusedEmail, + ], + selectedEmailId: selectedEmail.id, + selectedThreadId: selectedEmail.threadId, + focusedThreadEmailId: staleFocusedEmail.id, + viewMode: "split", + }); + }); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + await expect(palette.getByText("Current split link")).toBeVisible({ timeout: 3000 }); + await expect(palette.getByText("zoom.us/j/123456789", { exact: true })).toBeHidden(); + + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); + }); + + test("Cmd+O caps large result sets while keeping search complete", async () => { + await page.evaluate(() => { + const store = (window as unknown as { __ZUSTAND_STORE__?: TestStore }).__ZUSTAND_STORE__; + if (!store) return; + + const state = store.getState(); + const syntheticEmail = { + id: "e2e-openables-many-links", + threadId: "thread-e2e-openables-many-links", + subject: "Many links", + from: "Load Test ", + to: "Test ", + date: new Date().toISOString(), + body: Array.from( + { length: 120 }, + (_, index) => + `Synthetic link ${index + 1}`, + ).join("
"), + attachments: [], + }; + + store.setState({ + emails: [...state.emails.filter((email) => email.id !== syntheticEmail.id), syntheticEmail], + selectedEmailId: syntheticEmail.id, + selectedThreadId: syntheticEmail.threadId, + focusedThreadEmailId: null, + viewMode: "split", + }); + }); + + await page.keyboard.press("ControlOrMeta+o"); + + const palette = page.getByRole("dialog", { name: "Open Links & Attachments" }); + const input = palette.locator('input[placeholder="Open Links & Attachments..."]'); + await expect(input).toBeVisible({ timeout: 3000 }); + await expect(palette.locator("[data-index]")).toHaveCount(100); + await expect( + palette.getByText(/20 more items match\. Keep\s+typing to narrow\./), + ).toBeVisible(); + + await input.fill("Synthetic link 120"); + await expect(palette.locator("[data-index]")).toHaveCount(1); + await expect(palette.getByText("Synthetic link 120", { exact: true })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); + }); +}); diff --git a/tests/unit/open-links-attachments.spec.ts b/tests/unit/open-links-attachments.spec.ts new file mode 100644 index 00000000..b9a5e01c --- /dev/null +++ b/tests/unit/open-links-attachments.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from "@playwright/test"; +import type { DashboardEmail } from "../../src/shared/types"; +import { formatFileSize, isPreviewable } from "../../src/renderer/utils/attachments"; +import { buildOpenables, itemMatches, type OpenableItem } from "../../src/renderer/utils/openables"; + +test.describe("attachment helpers", () => { + test("formats byte sizes with stable units", () => { + expect(formatFileSize(0)).toBe("0 B"); + expect(formatFileSize(512)).toBe("512 B"); + expect(formatFileSize(1536)).toBe("1.5 KB"); + expect(formatFileSize(1024 * 1024 * 2.25)).toBe("2.3 MB"); + }); + + test("detects previewable attachment MIME types", () => { + expect(isPreviewable("application/pdf")).toBe(true); + expect(isPreviewable("image/png")).toBe(true); + expect(isPreviewable("application/vnd.ms-excel")).toBe(false); + }); +}); + +test.describe("openable item helpers", () => { + test("filters attachments that cannot be opened", () => { + const email: DashboardEmail = { + id: "email-1", + threadId: "thread-1", + subject: "Attachments", + from: "sender@example.com", + to: "recipient@example.com", + date: "2026-01-01T00:00:00.000Z", + body: "", + attachments: [ + { + id: "missing-gmail-id", + filename: "remote-placeholder.pdf", + mimeType: "application/pdf", + size: 1000, + }, + { + id: "downloadable", + attachmentId: "gmail-attachment-id", + filename: "invoice.pdf", + mimeType: "application/pdf", + size: 2048, + }, + ], + }; + + expect(buildOpenables(email)).toEqual([ + { + kind: "attachment", + id: "attachment:downloadable", + label: "invoice.pdf", + metadata: "2.0 KB - application/pdf", + attachment: email.attachments?.[1], + }, + ]); + }); + + test("matches query tokens across labels, metadata, and link URLs", () => { + const attachmentItem: OpenableItem = { + kind: "attachment", + id: "attachment:invoice", + label: "January invoice", + metadata: "20.0 KB - application/pdf", + attachment: { + id: "invoice", + attachmentId: "gmail-attachment-id", + filename: "January invoice", + mimeType: "application/pdf", + size: 20 * 1024, + }, + }; + const linkItem: OpenableItem = { + kind: "link", + id: "link:https://billing.example.com/view?token=abc", + label: "Billing portal", + metadata: "billing.example.com/view?token=abc", + url: "https://billing.example.com/view?token=abc", + }; + + expect(itemMatches(attachmentItem, "invoice pdf")).toBe(true); + expect(itemMatches(attachmentItem, "invoice zip")).toBe(false); + expect(itemMatches(linkItem, "billing token")).toBe(true); + }); +});