From e2d0c20b6d82f53310354f2ec2f4bd8bb6155942 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Tue, 30 Jun 2026 23:05:47 -0400 Subject: [PATCH 1/7] Add open links and attachments palette --- src/main/demo/fake-inbox.ts | 1 + src/renderer/App.tsx | 9 + src/renderer/components/EmailDetail.tsx | 4 +- src/renderer/components/KeyboardHints.tsx | 5 +- .../OpenLinksAttachmentsPalette.tsx | 521 ++++++++++++++++++ src/renderer/hooks/useKeyboardShortcuts.ts | 26 +- src/renderer/store/index.ts | 6 + tests/e2e/open-links-attachments.spec.ts | 189 +++++++ 8 files changed, 755 insertions(+), 6 deletions(-) create mode 100644 src/renderer/components/OpenLinksAttachmentsPalette.tsx create mode 100644 tests/e2e/open-links-attachments.spec.ts 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 a14a72f8..a10ccd38 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); @@ -2288,6 +2291,12 @@ export default function App() { }} /> + {/* Open Links & Attachments Palette */} + + {/* Agent Command Palette */} { // Let modifier combos (Cmd+C, Cmd+V, etc.) work natively in the iframe if (e.metaKey || e.ctrlKey) { - // Only forward Cmd+K, Cmd+, and Cmd+F which are app-level shortcuts - if (e.key !== "k" && e.key !== "," && e.key !== "f") return; + // Only forward app-level shortcuts. + if (e.key !== "k" && e.key !== "," && e.key !== "f" && e.key !== "o") return; } window.dispatchEvent( new KeyboardEvent("keydown", { diff --git a/src/renderer/components/KeyboardHints.tsx b/src/renderer/components/KeyboardHints.tsx index c5d4f33c..e0a33935 100644 --- a/src/renderer/components/KeyboardHints.tsx +++ b/src/renderer/components/KeyboardHints.tsx @@ -16,6 +16,7 @@ const DEFAULT_HINTS: Hint[] = [ { key: "/", label: "search" }, { key: "b", label: "sidebar" }, { key: "\u2318K", label: "commands" }, + { key: "\u2318O", label: "open links" }, ]; const BATCH_HINTS: Hint[] = [ @@ -34,6 +35,7 @@ const FULL_VIEW_HINTS: Hint[] = [ { key: "f", label: "forward" }, { key: "e", label: "archive" }, { key: "u", label: "unread" }, + { key: "\u2318O", label: "open links" }, ]; const SEARCH_RESULTS_HINTS: Hint[] = [ @@ -66,12 +68,13 @@ export function KeyboardHints() { composeState, isSearchOpen, isCommandPaletteOpen, + isOpenLinksAttachmentsOpen, activeSearchQuery, selectedThreadIds, } = useAppStore(); // Don't show hints when search or command palette is open - if (isSearchOpen || isCommandPaletteOpen) { + if (isSearchOpen || isCommandPaletteOpen || isOpenLinksAttachmentsOpen) { return null; } diff --git a/src/renderer/components/OpenLinksAttachmentsPalette.tsx b/src/renderer/components/OpenLinksAttachmentsPalette.tsx new file mode 100644 index 00000000..ce0945de --- /dev/null +++ b/src/renderer/components/OpenLinksAttachmentsPalette.tsx @@ -0,0 +1,521 @@ +import { + type KeyboardEvent as ReactKeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { AttachmentMeta, DashboardEmail } from "../../shared/types"; +import { useAppStore } from "../store"; +import { AttachmentPreviewModal } from "./AttachmentList"; + +type OpenableLink = { + kind: "link"; + id: string; + label: string; + metadata: string; + url: string; +}; + +type OpenableAttachment = { + kind: "attachment"; + id: string; + label: string; + metadata: string; + attachment: AttachmentMeta; +}; + +type OpenableItem = OpenableLink | OpenableAttachment; + +const MAX_RENDERED_OPENABLE_ITEMS = 100; + +type EmailSuccessResponse = { + success: true; + data: DashboardEmail; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isEmailResponse(value: unknown): value is EmailSuccessResponse { + if (!isRecord(value) || value.success !== true || !isRecord(value.data)) return false; + return typeof value.data.id === "string"; +} + +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]}`; +} + +function isPreviewable(mimeType: string): boolean { + return mimeType.startsWith("image/") || mimeType === "application/pdf"; +} + +function displayUrl(url: URL): string { + const path = url.pathname === "/" ? "" : url.pathname; + return `${url.hostname}${path}`; +} + +function normalizeLabel(label: string, fallback: string): string { + const cleaned = label.replace(/\s+/g, " ").trim(); + return cleaned || fallback; +} + +function trimUrlCandidate(candidate: string): string { + return candidate.replace(/[),.;!?]+$/g, ""); +} + +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 = trimUrlCandidate(rawHref); + 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") ?? ""; + addLink(rawHref, anchor.textContent || title || ariaLabel); + } + + const text = doc.body?.textContent ?? body; + const urlMatches = text.matchAll(/https?:\/\/[^\s<>"']+/gi); + for (const match of urlMatches) { + addLink(match[0], match[0]); + } + + return links; +} + +function attachmentMetadata(attachment: AttachmentMeta): string { + const parts = [formatFileSize(attachment.size)]; + if (attachment.mimeType) parts.push(attachment.mimeType); + return parts.join(" - "); +} + +function buildOpenables(email: DashboardEmail | null): OpenableItem[] { + if (!email) return []; + + const links = extractLinks(email.body ?? ""); + const attachments: OpenableAttachment[] = (email.attachments ?? []).map((attachment) => ({ + kind: "attachment", + id: `attachment:${attachment.id}`, + label: attachment.filename, + metadata: attachmentMetadata(attachment), + attachment, + })); + + return [...links, ...attachments]; +} + +function itemMatches(item: OpenableItem, query: string): boolean { + if (!query.trim()) return true; + const needle = query.trim().toLowerCase(); + return ( + item.label.toLowerCase().includes(needle) || + item.metadata.toLowerCase().includes(needle) || + (item.kind === "link" && item.url.toLowerCase().includes(needle)) + ); +} + +const ICONS = { + command: "M13 10V3L4 14h7v7l9-11h-7z", + link: "M13.19 8.688a4.5 4.5 0 016.364 6.364l-1.768 1.768a4.5 4.5 0 01-6.364 0M10.81 15.312a4.5 4.5 0 01-6.364-6.364L6.214 7.18a4.5 4.5 0 016.364 0", + file: "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5A3.375 3.375 0 0010.125 2.25H6.75A2.25 2.25 0 004.5 4.5v15A2.25 2.25 0 006.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-5.25z", +}; + +function PaletteIcon({ + path, + className = "w-5 h-5 text-gray-400 dark:text-gray-500 flex-shrink-0", + strokeWidth = 1.5, +}: { + path: string; + className?: string; + strokeWidth?: number; +}) { + return ( + + + + ); +} + +function RowIcon({ item, isSelected }: { item: OpenableItem; isSelected: boolean }) { + return ( + + ); +} + +export function OpenLinksAttachmentsPalette({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [loadedEmail, setLoadedEmail] = useState(null); + const [previewAttachment, setPreviewAttachment] = useState<{ + attachment: AttachmentMeta; + data: string; + } | null>(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + const emails = useAppStore((s) => s.emails); + const selectedEmailId = useAppStore((s) => s.selectedEmailId); + const focusedThreadEmailId = useAppStore((s) => s.focusedThreadEmailId); + const viewMode = useAppStore((s) => s.viewMode); + const currentAccountId = useAppStore((s) => s.currentAccountId); + + const sourceEmailId = + viewMode === "full" ? (focusedThreadEmailId ?? selectedEmailId) : selectedEmailId; + const storeEmail = useMemo( + () => emails.find((email) => email.id === sourceEmailId) ?? null, + [emails, sourceEmailId], + ); + const email = loadedEmail ?? storeEmail; + const accountId = email?.accountId ?? currentAccountId; + + useEffect(() => { + if (isOpen) { + setQuery(""); + setSelectedIndex(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } else { + setLoadedEmail(null); + setPreviewAttachment(null); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen || !sourceEmailId) return; + if (storeEmail?.body) { + setLoadedEmail(null); + return; + } + + let cancelled = false; + window.api.gmail + .getEmail(sourceEmailId) + .then((response: unknown) => { + if (!cancelled && isEmailResponse(response)) { + setLoadedEmail(response.data); + } + }) + .catch((error: unknown) => { + console.error("Failed to load email for openables:", error); + }); + + return () => { + cancelled = true; + }; + }, [isOpen, sourceEmailId, storeEmail?.body]); + + const openableItems = useMemo(() => buildOpenables(email), [email]); + + const filteredItems = useMemo( + () => openableItems.filter((item) => itemMatches(item, query)), + [openableItems, query], + ); + + const visibleItems = useMemo( + () => filteredItems.slice(0, MAX_RENDERED_OPENABLE_ITEMS), + [filteredItems], + ); + + const groupedItems = useMemo(() => { + return { + links: visibleItems.filter((item): item is OpenableLink => item.kind === "link"), + attachments: visibleItems.filter( + (item): item is OpenableAttachment => item.kind === "attachment", + ), + }; + }, [visibleItems]); + + const flatItems = useMemo( + () => [...groupedItems.links, ...groupedItems.attachments], + [groupedItems], + ); + const hiddenMatchCount = Math.max(filteredItems.length - visibleItems.length, 0); + + useEffect(() => { + setSelectedIndex(0); + }, [query, sourceEmailId]); + + useEffect(() => { + if (flatItems.length === 0 && selectedIndex !== 0) { + setSelectedIndex(0); + return; + } + if (selectedIndex >= flatItems.length) { + setSelectedIndex(Math.max(flatItems.length - 1, 0)); + } + }, [flatItems.length, selectedIndex]); + + useEffect(() => { + if (!listRef.current) return; + const selected = listRef.current.querySelector(`[data-index="${selectedIndex}"]`); + selected?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const openAttachment = useCallback( + async (attachment: AttachmentMeta) => { + if (!email || !accountId || !attachment.attachmentId) return; + + if (isPreviewable(attachment.mimeType)) { + const result = await window.api.attachments.preview( + email.id, + attachment.attachmentId, + accountId, + ); + if (result.success && result.data) { + setPreviewAttachment({ attachment, data: result.data.data }); + return; + } + } + + await window.api.attachments.download( + email.id, + attachment.attachmentId, + attachment.filename, + accountId, + ); + }, + [accountId, email], + ); + + const executeItem = useCallback( + (item: OpenableItem) => { + if (item.kind === "link") { + onClose(); + requestAnimationFrame(() => { + window.open(item.url, "_blank", "noopener,noreferrer"); + }); + return; + } + + void openAttachment(item.attachment); + }, + [onClose, openAttachment], + ); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && /^[1-9]$/.test(event.key)) { + event.preventDefault(); + const index = Number(event.key) - 1; + const item = flatItems[index]; + if (item) { + executeItem(item); + } + return; + } + + switch (event.key) { + case "Escape": + event.preventDefault(); + event.stopPropagation(); + onClose(); + break; + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((index) => Math.min(index + 1, flatItems.length - 1)); + break; + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((index) => Math.max(index - 1, 0)); + break; + case "Enter": + event.preventDefault(); + event.stopPropagation(); + if (flatItems[selectedIndex]) { + executeItem(flatItems[selectedIndex]); + } + break; + } + }, + [executeItem, flatItems, onClose, selectedIndex], + ); + + if (!isOpen) return null; + + let flatIndex = 0; + const hasItems = flatItems.length > 0; + + const renderRows = (title: string, items: OpenableItem[]) => { + if (items.length === 0) return null; + + return ( +
+
+ {title} +
+ {items.map((item) => { + const index = flatIndex++; + const isSelected = index === selectedIndex; + return ( + + ); + })} +
+ ); + }; + + return ( + <> +
+
+
+
+ + setQuery(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="Open Links & Attachments..." + className="flex-1 text-base outline-none placeholder-gray-400 dark:text-gray-100 dark:placeholder-gray-500 bg-transparent" + /> + + ⌘O + + + esc + +
+ +
+ {!hasItems ? ( +
+ {query ? "No matching links or attachments" : "No links or attachments"} +
+ ) : ( + <> + {renderRows("Links", groupedItems.links)} + {renderRows("Attachments", groupedItems.attachments)} + {hiddenMatchCount > 0 && ( +
+ {hiddenMatchCount} more {hiddenMatchCount === 1 ? "item" : "items"} match. Keep + typing to narrow. +
+ )} + + )} +
+ +
+ + ↑↓{" "} + navigate + + + Enter open + + + Esc close + +
+
+
+ + {previewAttachment && ( + setPreviewAttachment(null)} + /> + )} + + ); +} diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 7403f5ca..b61713cf 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -50,10 +50,17 @@ function isInputFocused(): boolean { // 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 +179,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 @@ -283,6 +295,13 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) return; } + // Cmd+O: open links and attachments for the currently focused email. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "o") { + e.preventDefault(); + state.openLinksAttachments(); + return; + } + // Let standard modifier shortcuts (Cmd+C, Cmd+V, Cmd+X, etc.) pass through. // Cmd/Ctrl always bail — these are OS-level shortcuts. if (e.metaKey || e.ctrlKey) { @@ -1178,6 +1197,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: "⌘O", description: "Open links and attachments" }, ...(isGmail ? [ { key: "z", description: "Undo last action" }, 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/tests/e2e/open-links-attachments.spec.ts b/tests/e2e/open-links-attachments.spec.ts new file mode 100644 index 00000000..ab6b1d27 --- /dev/null +++ b/tests/e2e/open-links-attachments.spec.ts @@ -0,0 +1,189 @@ +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[]; + }; + 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 page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + }); + + test("Cmd+O includes bare URLs from plain text email bodies", async () => { + await page.locator("button[title*='Search']").first().click(); + const searchInput = page.locator("input[placeholder*='Search emails']"); + await expect(searchInput).toBeVisible({ timeout: 5000 }); + await searchInput.fill("PAYMENT_TIMEOUT"); + + const incidentResult = page + .locator("button") + .filter({ hasText: "URGENT: Production issue affecting checkout flow" }) + .first(); + await expect(incidentResult).toBeVisible({ timeout: 5000 }); + await incidentResult.click(); + await expect( + page.getByRole("heading", { name: "URGENT: Production issue affecting checkout flow" }), + ).toBeVisible(); + + 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 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(); + }); +}); From 57dbd4bad819f87a2c617396d4c6334d9ee6b6b6 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Wed, 1 Jul 2026 00:42:08 -0400 Subject: [PATCH 2/7] address review bot feedback (reviewloop iteration 1) --- .../OpenLinksAttachmentsPalette.tsx | 26 +++- tests/e2e/open-links-attachments.spec.ts | 125 ++++++++++++++++-- 2 files changed, 134 insertions(+), 17 deletions(-) diff --git a/src/renderer/components/OpenLinksAttachmentsPalette.tsx b/src/renderer/components/OpenLinksAttachmentsPalette.tsx index ce0945de..8f7f661b 100644 --- a/src/renderer/components/OpenLinksAttachmentsPalette.tsx +++ b/src/renderer/components/OpenLinksAttachmentsPalette.tsx @@ -81,7 +81,7 @@ function extractLinks(body: string): OpenableLink[] { const addLink = (rawHref: string, labelText: string) => { if (!rawHref) return; - const href = trimUrlCandidate(rawHref); + const href = rawHref.trim(); if (!href) return; const absoluteHref = href.startsWith("//") ? `https:${href}` : href; @@ -119,7 +119,8 @@ function extractLinks(body: string): OpenableLink[] { const text = doc.body?.textContent ?? body; const urlMatches = text.matchAll(/https?:\/\/[^\s<>"']+/gi); for (const match of urlMatches) { - addLink(match[0], match[0]); + const href = trimUrlCandidate(match[0]); + addLink(href, href); } return links; @@ -238,6 +239,21 @@ export function OpenLinksAttachmentsPalette({ } }, [isOpen]); + useEffect(() => { + if (!previewAttachment) return; + + const handlePreviewEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + setPreviewAttachment(null); + }; + + window.addEventListener("keydown", handlePreviewEscape, true); + return () => window.removeEventListener("keydown", handlePreviewEscape, true); + }, [previewAttachment]); + useEffect(() => { if (!isOpen || !sourceEmailId) return; if (storeEmail?.body) { @@ -366,6 +382,10 @@ export function OpenLinksAttachmentsPalette({ case "Escape": event.preventDefault(); event.stopPropagation(); + if (previewAttachment) { + setPreviewAttachment(null); + return; + } onClose(); break; case "ArrowDown": @@ -385,7 +405,7 @@ export function OpenLinksAttachmentsPalette({ break; } }, - [executeItem, flatItems, onClose, selectedIndex], + [executeItem, flatItems, onClose, previewAttachment, selectedIndex], ); if (!isOpen) return null; diff --git a/tests/e2e/open-links-attachments.spec.ts b/tests/e2e/open-links-attachments.spec.ts index ab6b1d27..65d9f77d 100644 --- a/tests/e2e/open-links-attachments.spec.ts +++ b/tests/e2e/open-links-attachments.spec.ts @@ -60,21 +60,75 @@ test.describe("Open Links & Attachments palette", () => { await expect(input).toBeHidden(); }); + 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.locator("button[title*='Search']").first().click(); - const searchInput = page.locator("input[placeholder*='Search emails']"); - await expect(searchInput).toBeVisible({ timeout: 5000 }); - await searchInput.fill("PAYMENT_TIMEOUT"); - - const incidentResult = page - .locator("button") - .filter({ hasText: "URGENT: Production issue affecting checkout flow" }) - .first(); - await expect(incidentResult).toBeVisible({ timeout: 5000 }); - await incidentResult.click(); - await expect( - page.getByRole("heading", { name: "URGENT: Production issue affecting checkout flow" }), - ).toBeVisible(); + 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"); @@ -87,6 +141,49 @@ test.describe("Open Links & Attachments palette", () => { 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.

+ `, + 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(); + + 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__; From 5664ca062f71c52631bfffdc95e6abf67c2693f8 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Wed, 1 Jul 2026 12:04:53 -0400 Subject: [PATCH 3/7] Harden open links attachment palette --- src/renderer/components/AttachmentList.tsx | 13 +- src/renderer/components/KeyboardHints.tsx | 86 +++--- .../OpenLinksAttachmentsPalette.tsx | 285 +++++++----------- src/renderer/hooks/useKeyboardShortcuts.ts | 13 +- src/renderer/utils/attachments.ts | 21 ++ src/renderer/utils/openables.ts | 128 ++++++++ src/renderer/utils/platform.ts | 12 + tests/e2e/open-links-attachments.spec.ts | 63 ++++ tests/unit/open-links-attachments.spec.ts | 85 ++++++ 9 files changed, 473 insertions(+), 233 deletions(-) create mode 100644 src/renderer/utils/attachments.ts create mode 100644 src/renderer/utils/openables.ts create mode 100644 src/renderer/utils/platform.ts create mode 100644 tests/unit/open-links-attachments.spec.ts diff --git a/src/renderer/components/AttachmentList.tsx b/src/renderer/components/AttachmentList.tsx index c110c590..dc0e0aca 100644 --- a/src/renderer/components/AttachmentList.tsx +++ b/src/renderer/components/AttachmentList.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import type { AttachmentMeta, IpcResponse } from "../../shared/types"; +import { formatFileSize, isPreviewable } from "../utils/attachments"; declare global { interface Window { @@ -28,14 +29,6 @@ declare global { } } -function formatFileSize(bytes: number): string { - if (bytes === 0) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const size = bytes / Math.pow(1024, i); - return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; -} - function getFileIcon(mimeType: string): string { if (mimeType.startsWith("image/")) return "image"; if (mimeType === "application/pdf") return "pdf"; @@ -78,10 +71,6 @@ function FileIcon({ type }: { type: string }) { ); } -function isPreviewable(mimeType: string): boolean { - return mimeType.startsWith("image/") || mimeType === "application/pdf"; -} - /** * Shows attachment list for an email message (in thread view). * Supports download and preview. diff --git a/src/renderer/components/KeyboardHints.tsx b/src/renderer/components/KeyboardHints.tsx index e0a33935..0ab9ba12 100644 --- a/src/renderer/components/KeyboardHints.tsx +++ b/src/renderer/components/KeyboardHints.tsx @@ -1,42 +1,49 @@ import { useAppStore } from "../store"; +import { formatPlatformShortcut } from "../utils/platform"; type Hint = { key: string; label: string; }; -const DEFAULT_HINTS: Hint[] = [ - { key: "j/k", label: "navigate" }, - { key: "Enter", label: "open" }, - { key: "r", label: "reply" }, - { key: "e", label: "archive" }, - { key: "u", label: "unread" }, - { key: "x", label: "select" }, - { key: "c", label: "compose" }, - { key: "/", label: "search" }, - { key: "b", label: "sidebar" }, - { key: "\u2318K", label: "commands" }, - { key: "\u2318O", label: "open links" }, -]; +function getDefaultHints(): Hint[] { + return [ + { key: "j/k", label: "navigate" }, + { key: "Enter", label: "open" }, + { key: "r", label: "reply" }, + { key: "e", label: "archive" }, + { key: "u", label: "unread" }, + { key: "x", label: "select" }, + { key: "c", label: "compose" }, + { key: "/", label: "search" }, + { key: "b", label: "sidebar" }, + { key: formatPlatformShortcut("K"), label: "commands" }, + { key: formatPlatformShortcut("O"), label: "open links" }, + ]; +} -const BATCH_HINTS: Hint[] = [ - { key: "e", label: "archive" }, - { key: "#", label: "trash" }, - { key: "u", label: "unread" }, - { key: "Cmd+A", label: "select all" }, - { key: "Esc", label: "deselect" }, -]; +function getBatchHints(): Hint[] { + return [ + { key: "e", label: "archive" }, + { key: "#", label: "trash" }, + { key: "u", label: "unread" }, + { key: formatPlatformShortcut("A"), label: "select all" }, + { key: "Esc", label: "deselect" }, + ]; +} -const FULL_VIEW_HINTS: Hint[] = [ - { key: "Esc", label: "back" }, - { key: "j/k", label: "prev/next" }, - { key: "Enter", label: "reply" }, - { key: "R", label: "reply all" }, - { key: "f", label: "forward" }, - { key: "e", label: "archive" }, - { key: "u", label: "unread" }, - { key: "\u2318O", label: "open links" }, -]; +function getFullViewHints(): Hint[] { + return [ + { key: "Esc", label: "back" }, + { key: "j/k", label: "prev/next" }, + { key: "Enter", label: "reply" }, + { key: "R", label: "reply all" }, + { key: "f", label: "forward" }, + { key: "e", label: "archive" }, + { key: "u", label: "unread" }, + { key: formatPlatformShortcut("O"), label: "open links" }, + ]; +} const SEARCH_RESULTS_HINTS: Hint[] = [ { key: "j/k", label: "navigate" }, @@ -46,10 +53,12 @@ const SEARCH_RESULTS_HINTS: Hint[] = [ { key: "Esc", label: "back to inbox" }, ]; -const COMPOSE_HINTS: Hint[] = [ - { key: "Cmd+Enter", label: "send" }, - { key: "Esc", label: "cancel" }, -]; +function getComposeHints(): Hint[] { + return [ + { key: formatPlatformShortcut("Enter"), label: "send" }, + { key: "Esc", label: "cancel" }, + ]; +} function HintItem({ hint }: { hint: Hint }) { return ( @@ -80,9 +89,10 @@ export function KeyboardHints() { // Show compose hints when composing if (composeState?.isOpen) { + const composeHints = getComposeHints(); return (
- {COMPOSE_HINTS.map((hint) => ( + {composeHints.map((hint) => ( ))}
@@ -92,12 +102,12 @@ export function KeyboardHints() { // Select hints based on context const hints = selectedThreadIds.size > 0 - ? BATCH_HINTS + ? getBatchHints() : activeSearchQuery && viewMode !== "full" ? SEARCH_RESULTS_HINTS : viewMode === "full" - ? FULL_VIEW_HINTS - : DEFAULT_HINTS; + ? getFullViewHints() + : getDefaultHints(); return (
diff --git a/src/renderer/components/OpenLinksAttachmentsPalette.tsx b/src/renderer/components/OpenLinksAttachmentsPalette.tsx index 8f7f661b..5d1592b0 100644 --- a/src/renderer/components/OpenLinksAttachmentsPalette.tsx +++ b/src/renderer/components/OpenLinksAttachmentsPalette.tsx @@ -6,157 +6,20 @@ import { useRef, useState, } from "react"; -import type { AttachmentMeta, DashboardEmail } from "../../shared/types"; +import type { AttachmentMeta, DashboardEmail, IpcResponse } from "../../shared/types"; import { useAppStore } from "../store"; +import { isPreviewable } from "../utils/attachments"; +import { + buildOpenables, + itemMatches, + MAX_RENDERED_OPENABLE_ITEMS, + type OpenableAttachment, + type OpenableItem, + type OpenableLink, +} from "../utils/openables"; +import { formatPlatformShortcut } from "../utils/platform"; import { AttachmentPreviewModal } from "./AttachmentList"; -type OpenableLink = { - kind: "link"; - id: string; - label: string; - metadata: string; - url: string; -}; - -type OpenableAttachment = { - kind: "attachment"; - id: string; - label: string; - metadata: string; - attachment: AttachmentMeta; -}; - -type OpenableItem = OpenableLink | OpenableAttachment; - -const MAX_RENDERED_OPENABLE_ITEMS = 100; - -type EmailSuccessResponse = { - success: true; - data: DashboardEmail; -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isEmailResponse(value: unknown): value is EmailSuccessResponse { - if (!isRecord(value) || value.success !== true || !isRecord(value.data)) return false; - return typeof value.data.id === "string"; -} - -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]}`; -} - -function isPreviewable(mimeType: string): boolean { - return mimeType.startsWith("image/") || mimeType === "application/pdf"; -} - -function displayUrl(url: URL): string { - const path = url.pathname === "/" ? "" : url.pathname; - return `${url.hostname}${path}`; -} - -function normalizeLabel(label: string, fallback: string): string { - const cleaned = label.replace(/\s+/g, " ").trim(); - return cleaned || fallback; -} - -function trimUrlCandidate(candidate: string): string { - return candidate.replace(/[),.;!?]+$/g, ""); -} - -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") ?? ""; - addLink(rawHref, anchor.textContent || title || ariaLabel); - } - - const text = doc.body?.textContent ?? body; - const urlMatches = text.matchAll(/https?:\/\/[^\s<>"']+/gi); - for (const match of urlMatches) { - const href = trimUrlCandidate(match[0]); - addLink(href, href); - } - - return links; -} - -function attachmentMetadata(attachment: AttachmentMeta): string { - const parts = [formatFileSize(attachment.size)]; - if (attachment.mimeType) parts.push(attachment.mimeType); - return parts.join(" - "); -} - -function buildOpenables(email: DashboardEmail | null): OpenableItem[] { - if (!email) return []; - - const links = extractLinks(email.body ?? ""); - const attachments: OpenableAttachment[] = (email.attachments ?? []).map((attachment) => ({ - kind: "attachment", - id: `attachment:${attachment.id}`, - label: attachment.filename, - metadata: attachmentMetadata(attachment), - attachment, - })); - - return [...links, ...attachments]; -} - -function itemMatches(item: OpenableItem, query: string): boolean { - if (!query.trim()) return true; - const needle = query.trim().toLowerCase(); - return ( - item.label.toLowerCase().includes(needle) || - item.metadata.toLowerCase().includes(needle) || - (item.kind === "link" && item.url.toLowerCase().includes(needle)) - ); -} - const ICONS = { command: "M13 10V3L4 14h7v7l9-11h-7z", link: "M13.19 8.688a4.5 4.5 0 016.364 6.364l-1.768 1.768a4.5 4.5 0 01-6.364 0M10.81 15.312a4.5 4.5 0 01-6.364-6.364L6.214 7.18a4.5 4.5 0 016.364 0", @@ -205,7 +68,12 @@ export function OpenLinksAttachmentsPalette({ }) { const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); - const [loadedEmail, setLoadedEmail] = useState(null); + const [loadedEmailState, setLoadedEmailState] = useState<{ + id: string; + email: DashboardEmail; + } | null>(null); + const [loadingEmailId, setLoadingEmailId] = useState(null); + const [openingAttachmentId, setOpeningAttachmentId] = useState(null); const [previewAttachment, setPreviewAttachment] = useState<{ attachment: AttachmentMeta; data: string; @@ -225,8 +93,10 @@ export function OpenLinksAttachmentsPalette({ () => emails.find((email) => email.id === sourceEmailId) ?? null, [emails, sourceEmailId], ); - const email = loadedEmail ?? storeEmail; + const email = loadedEmailState?.id === sourceEmailId ? loadedEmailState.email : storeEmail; const accountId = email?.accountId ?? currentAccountId; + const isLoadingEmail = loadingEmailId === sourceEmailId; + const openShortcut = useMemo(() => formatPlatformShortcut("O"), []); useEffect(() => { if (isOpen) { @@ -234,7 +104,9 @@ export function OpenLinksAttachmentsPalette({ setSelectedIndex(0); requestAnimationFrame(() => inputRef.current?.focus()); } else { - setLoadedEmail(null); + setLoadedEmailState(null); + setLoadingEmailId(null); + setOpeningAttachmentId(null); setPreviewAttachment(null); } }, [isOpen]); @@ -255,22 +127,39 @@ export function OpenLinksAttachmentsPalette({ }, [previewAttachment]); useEffect(() => { - if (!isOpen || !sourceEmailId) return; + if (!isOpen || !sourceEmailId) { + setLoadingEmailId(null); + return; + } + if (storeEmail?.body) { - setLoadedEmail(null); + setLoadedEmailState(null); + setLoadingEmailId(null); return; } let cancelled = false; + setLoadingEmailId(sourceEmailId); + window.api.gmail .getEmail(sourceEmailId) .then((response: unknown) => { - if (!cancelled && isEmailResponse(response)) { - setLoadedEmail(response.data); + if (cancelled) return; + + const emailResponse = response as IpcResponse; + if (emailResponse.success && emailResponse.data.id === sourceEmailId) { + setLoadedEmailState({ id: sourceEmailId, email: emailResponse.data }); + } else if (!emailResponse.success) { + console.error("Failed to load email for openables:", emailResponse.error); } }) .catch((error: unknown) => { + if (cancelled) return; console.error("Failed to load email for openables:", error); + }) + .finally(() => { + if (cancelled) return; + setLoadingEmailId((current) => (current === sourceEmailId ? null : current)); }); return () => { @@ -326,29 +215,36 @@ export function OpenLinksAttachmentsPalette({ }, [selectedIndex]); const openAttachment = useCallback( - async (attachment: AttachmentMeta) => { - if (!email || !accountId || !attachment.attachmentId) return; + async (attachment: OpenableAttachment["attachment"]) => { + if (!email || !accountId || openingAttachmentId) return; + + setOpeningAttachmentId(attachment.id); + try { + if (isPreviewable(attachment.mimeType)) { + const result = await window.api.attachments.preview( + email.id, + attachment.attachmentId, + accountId, + ); + if (result.success && result.data) { + setPreviewAttachment({ attachment, data: result.data.data }); + return; + } + } - if (isPreviewable(attachment.mimeType)) { - const result = await window.api.attachments.preview( + await window.api.attachments.download( email.id, attachment.attachmentId, + attachment.filename, accountId, ); - if (result.success && result.data) { - setPreviewAttachment({ attachment, data: result.data.data }); - return; - } + } catch (error) { + console.error("Failed to open attachment:", error); + } finally { + setOpeningAttachmentId(null); } - - await window.api.attachments.download( - email.id, - attachment.attachmentId, - attachment.filename, - accountId, - ); }, - [accountId, email], + [accountId, email, openingAttachmentId], ); const executeItem = useCallback( @@ -390,15 +286,18 @@ export function OpenLinksAttachmentsPalette({ break; case "ArrowDown": event.preventDefault(); + if (flatItems.length === 0) return; setSelectedIndex((index) => Math.min(index + 1, flatItems.length - 1)); break; case "ArrowUp": event.preventDefault(); + if (flatItems.length === 0) return; setSelectedIndex((index) => Math.max(index - 1, 0)); break; case "Enter": event.preventDefault(); event.stopPropagation(); + if (flatItems.length === 0) return; if (flatItems[selectedIndex]) { executeItem(flatItems[selectedIndex]); } @@ -424,18 +323,25 @@ export function OpenLinksAttachmentsPalette({ {items.map((item) => { const index = flatIndex++; const isSelected = index === selectedIndex; + const isOpening = + item.kind === "attachment" && openingAttachmentId === item.attachment.id; + const isDisabled = item.kind === "attachment" && openingAttachmentId !== null; + return ( ); })} @@ -488,7 +415,7 @@ export function OpenLinksAttachmentsPalette({ className="flex-1 text-base outline-none placeholder-gray-400 dark:text-gray-100 dark:placeholder-gray-500 bg-transparent" /> - ⌘O + {openShortcut} esc @@ -498,7 +425,11 @@ export function OpenLinksAttachmentsPalette({
{!hasItems ? (
- {query ? "No matching links or attachments" : "No links or attachments"} + {isLoadingEmail + ? "Loading links and attachments..." + : query + ? "No matching links or attachments" + : "No links or attachments"}
) : ( <> diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index b61713cf..9f42c2a1 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 } from "../utils/platform"; declare global { interface Window { @@ -295,7 +296,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) return; } - // Cmd+O: open links and attachments for the currently focused email. + // Cmd+O / Ctrl+O: open links and attachments for the currently focused email. if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "o") { e.preventDefault(); state.openLinksAttachments(); @@ -1197,7 +1198,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: "⌘O", description: "Open links and attachments" }, + { key: formatPlatformShortcut("O"), description: "Open links and attachments" }, ...(isGmail ? [ { key: "z", description: "Undo last action" }, @@ -1206,7 +1207,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" }, @@ -1216,9 +1217,9 @@ 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" }, 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..eed3a680 --- /dev/null +++ b/src/renderer/utils/openables.ts @@ -0,0 +1,128 @@ +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 { + return candidate.replace(/[),.;!?]+$/g, ""); +} + +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 text = doc.body?.textContent ?? body; + 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 index 65d9f77d..66176e4d 100644 --- a/tests/e2e/open-links-attachments.spec.ts +++ b/tests/e2e/open-links-attachments.spec.ts @@ -56,6 +56,10 @@ test.describe("Open Links & Attachments palette", () => { 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(); }); @@ -184,6 +188,65 @@ Zoom: https://zoom.us/j/123456789`, 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__; 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); + }); +}); From cdaf2ade9dd986aa2c97fa0b31e303c35bd73805 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Mon, 6 Jul 2026 18:27:38 -0400 Subject: [PATCH 4/7] fix: address review findings for Cmd+O palette - Keep balanced parens in bare-text URLs (e.g. Wikipedia links) while still trimming surrounding punctuation; cover both cases in e2e tests - Handle Cmd+O before the input-focus/mode bails so it works in the same focus states as Cmd+K/J/F - Replace the hardcoded iframe shortcut allowlist in EmailDetail with a shared APP_SHORTCUT_MODIFIER_KEYS set, and forward Cmd+J too - Extract shared PaletteShell/usePaletteSelection and use them in CommandPalette, AgentCommandPalette, and OpenLinksAttachmentsPalette - Finish the platform shortcut-label migration (settings/help entries, ComposeToolbar, AgentPanel, EmailPreviewSidebar, undo toasts) so non-Mac UIs no longer mix "Cmd+" and "Ctrl+" labels - Restore KeyboardHints module-scope constants; drop a dead finally guard in the palette load effect Co-Authored-By: Claude Fable 5 --- .../components/AgentCommandPalette.tsx | 328 ++++++++---------- src/renderer/components/AgentPanel.tsx | 3 +- src/renderer/components/CommandPalette.tsx | 171 ++++----- src/renderer/components/ComposeToolbar.tsx | 5 +- src/renderer/components/EmailDetail.tsx | 4 +- .../components/EmailPreviewSidebar.tsx | 5 +- src/renderer/components/KeyboardHints.tsx | 85 ++--- .../OpenLinksAttachmentsPalette.tsx | 165 ++++----- src/renderer/components/PaletteShell.tsx | 170 +++++++++ src/renderer/components/UndoActionToast.tsx | 3 +- src/renderer/components/UndoSendToast.tsx | 3 +- src/renderer/hooks/useKeyboardShortcuts.ts | 28 +- src/renderer/utils/openables.ts | 22 +- tests/e2e/open-links-attachments.spec.ts | 12 + 14 files changed, 556 insertions(+), 448 deletions(-) create mode 100644 src/renderer/components/PaletteShell.tsx diff --git a/src/renderer/components/AgentCommandPalette.tsx b/src/renderer/components/AgentCommandPalette.tsx index da65e27c..e50e3839 100644 --- a/src/renderer/components/AgentCommandPalette.tsx +++ b/src/renderer/components/AgentCommandPalette.tsx @@ -1,5 +1,12 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useAppStore } from "../store"; +import { + PaletteFooter, + PaletteHeader, + PaletteResults, + PaletteShell, + usePaletteSelection, +} from "./PaletteShell"; import type { AgentContext } from "../../shared/agent-types"; import { trackEvent } from "../services/posthog"; @@ -155,9 +162,6 @@ interface AgentCommandPaletteProps { export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProps) { const [query, setQuery] = useState(""); - const [selectedIndex, setSelectedIndex] = useState(0); - const inputRef = useRef(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 b9901e2d..242db9ad 100644 --- a/src/renderer/components/AgentPanel.tsx +++ b/src/renderer/components/AgentPanel.tsx @@ -5,6 +5,7 @@ import { useAppStore } from "../store"; import type { ScopedAgentEvent, AgentTaskState, AgentTaskInfo } from "../../shared/agent-types"; import { AgentConfirmationDialog } from "./AgentConfirmationDialog"; import { trackEvent } from "../services/posthog"; +import { formatPlatformShortcut } from "../utils/platform"; function StatusChip({ status }: { status: AgentTaskState }) { const config: Record = { @@ -847,7 +848,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 && ( setQuery(event.target.value)} - onKeyDown={handleKeyDown} - placeholder="Open Links & Attachments..." - className="flex-1 text-base outline-none placeholder-gray-400 dark:text-gray-100 dark:placeholder-gray-500 bg-transparent" - /> - - {openShortcut} - + } + inputRef={inputRef} + query={query} + onQueryChange={setQuery} + onKeyDown={handleKeyDown} + placeholder="Open Links & Attachments..." + trailing={ - esc + {OPEN_SHORTCUT} -
- -
- {!hasItems ? ( -
- {isLoadingEmail - ? "Loading links and attachments..." - : query - ? "No matching links or attachments" - : "No links or attachments"} -
- ) : ( - <> - {renderRows("Links", groupedItems.links)} - {renderRows("Attachments", groupedItems.attachments)} - {hiddenMatchCount > 0 && ( -
- {hiddenMatchCount} more {hiddenMatchCount === 1 ? "item" : "items"} match. Keep - typing to narrow. -
- )} - - )} -
- -
- - ↑↓{" "} - navigate - - - Enter open - - - Esc close - -
-
- + } + /> + + + {!hasItems ? ( +
+ {isLoadingEmail + ? "Loading links and attachments..." + : query + ? "No matching links or attachments" + : "No links or attachments"} +
+ ) : ( + <> + {renderRows("Links", groupedItems.links)} + {renderRows("Attachments", groupedItems.attachments)} + {hiddenMatchCount > 0 && ( +
+ {hiddenMatchCount} more {hiddenMatchCount === 1 ? "item" : "items"} match. Keep + typing to narrow. +
+ )} + + )} +
+ + + {previewAttachment && ( (null); + const listRef = useRef(null); + + // Reset selection and focus the input when opened + useEffect(() => { + if (isOpen) { + setSelectedIndex(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [isOpen]); + + // Reset selection when query changes + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + // Keep selection in range when the list shrinks + useEffect(() => { + if (selectedIndex >= itemCount) { + setSelectedIndex(Math.max(itemCount - 1, 0)); + } + }, [itemCount, selectedIndex]); + + // 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 moveSelection = useCallback( + (delta: 1 | -1) => { + if (itemCount === 0) return; + setSelectedIndex((i) => Math.min(Math.max(i + delta, 0), itemCount - 1)); + }, + [itemCount], + ); + + return { selectedIndex, setSelectedIndex, inputRef, listRef, moveSelection }; +} + +/** Full-screen backdrop plus the centered palette panel. */ +export function PaletteShell({ + label, + onClose, + children, +}: { + label: string; + onClose: () => void; + children: ReactNode; +}) { + return ( +
+ {/* Backdrop */} +
+ + {/* Palette panel */} +
+ {children} +
+
+ ); +} + +/** Icon + search input + optional trailing kbd hints ("esc" is always shown). */ +export function PaletteHeader({ + icon, + inputRef, + query, + onQueryChange, + onKeyDown, + placeholder, + trailing, +}: { + icon: ReactNode; + inputRef: RefObject; + query: string; + onQueryChange: (value: string) => void; + onKeyDown: (event: ReactKeyboardEvent) => void; + placeholder: string; + trailing?: ReactNode; +}) { + return ( +
+ {icon} + 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 9f42c2a1..16cc4295 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -5,7 +5,7 @@ import { markNavigationActive } from "./useSyncBuffer"; import { mergeAndThreadSearchResults } from "../utils/searchResults"; import { draftMatchesSplit } from "../utils/split-conditions"; import { trackEvent } from "../services/posthog"; -import { formatPlatformShortcut } from "../utils/platform"; +import { formatPlatformShortcut, isMacPlatform } from "../utils/platform"; declare global { interface Window { @@ -49,6 +49,12 @@ 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 { @@ -255,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 / Ctrl+O: open links and attachments for the currently focused + // email. Handled before the input-focus/mode bails below so it works in + // the same focus states as Cmd+K/J/F. + if ((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. @@ -296,13 +311,6 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) return; } - // Cmd+O / Ctrl+O: open links and attachments for the currently focused email. - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "o") { - e.preventDefault(); - state.openLinksAttachments(); - return; - } - // Let standard modifier shortcuts (Cmd+C, Cmd+V, Cmd+X, etc.) pass through. // Cmd/Ctrl always bail — these are OS-level shortcuts. if (e.metaKey || e.ctrlKey) { @@ -1223,7 +1231,7 @@ export function getKeyboardShortcuts(bindings: "superhuman" | "gmail") { ], other: [ { key: "b", description: "Switch sidebar tab" }, - { key: "Cmd+,", description: "Settings" }, + { key: formatPlatformShortcut(","), description: "Settings" }, { key: "?", description: "Show shortcuts" }, ], }; diff --git a/src/renderer/utils/openables.ts b/src/renderer/utils/openables.ts index eed3a680..5822700f 100644 --- a/src/renderer/utils/openables.ts +++ b/src/renderer/utils/openables.ts @@ -33,7 +33,27 @@ function normalizeLabel(label: string, fallback: string): string { } function trimUrlCandidate(candidate: string): string { - return candidate.replace(/[),.;!?]+$/g, ""); + 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[] { diff --git a/tests/e2e/open-links-attachments.spec.ts b/tests/e2e/open-links-attachments.spec.ts index 66176e4d..d93dc7ee 100644 --- a/tests/e2e/open-links-attachments.spec.ts +++ b/tests/e2e/open-links-attachments.spec.ts @@ -161,6 +161,8 @@ Zoom: https://zoom.us/j/123456789`, 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: [], }; @@ -183,6 +185,16 @@ Zoom: https://zoom.us/j/123456789`, ).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(); From 4f0cbc96e7bdd7edd6c720209f794263baebf755 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Thu, 16 Jul 2026 14:53:35 -0400 Subject: [PATCH 5/7] Fix Cmd+O URL extraction across HTML boundaries --- src/renderer/utils/openables.ts | 26 +++++++++++--- tests/e2e/open-links-attachments.spec.ts | 43 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/renderer/utils/openables.ts b/src/renderer/utils/openables.ts index 5822700f..c828272a 100644 --- a/src/renderer/utils/openables.ts +++ b/src/renderer/utils/openables.ts @@ -103,11 +103,27 @@ export function extractLinks(body: string): OpenableLink[] { addLink(rawHref, textLabel || title || ariaLabel); } - const text = doc.body?.textContent ?? body; - const urlMatches = text.matchAll(/https?:\/\/[^\s<>"']+/gi); - for (const match of urlMatches) { - const href = trimUrlCandidate(match[0]); - addLink(href, href); + 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; diff --git a/tests/e2e/open-links-attachments.spec.ts b/tests/e2e/open-links-attachments.spec.ts index d93dc7ee..5b915bca 100644 --- a/tests/e2e/open-links-attachments.spec.ts +++ b/tests/e2e/open-links-attachments.spec.ts @@ -145,6 +145,49 @@ Zoom: https://zoom.us/j/123456789`, 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: `
--------------------
Source
--------------------
❤️
`, + 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__; From e31cff30e309225d0b231975358d7d6021069bee Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Thu, 16 Jul 2026 15:02:11 -0400 Subject: [PATCH 6/7] Cover exact Greenhouse URL markup --- tests/e2e/open-links-attachments.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/open-links-attachments.spec.ts b/tests/e2e/open-links-attachments.spec.ts index 5b915bca..f9953446 100644 --- a/tests/e2e/open-links-attachments.spec.ts +++ b/tests/e2e/open-links-attachments.spec.ts @@ -159,7 +159,7 @@ Zoom: https://zoom.us/j/123456789`, from: "Jobs ", to: "Test ", date: new Date().toISOString(), - body: `
--------------------
Source
--------------------
❤️
`, + body: `${greenhouseUrl}

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

Source
${greenhouseUrl}

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

❤️`, attachments: [], }; From 753a584edec0f912fee99c90dcabd18d1e4765c8 Mon Sep 17 00:00:00 2001 From: Mick Niepoth Date: Thu, 16 Jul 2026 15:35:58 -0400 Subject: [PATCH 7/7] address review bot feedback (reviewloop iteration 1) --- src/renderer/hooks/useKeyboardShortcuts.ts | 8 ++-- tests/e2e/open-links-attachments.spec.ts | 44 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 16cc4295..7765e793 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -268,10 +268,10 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) return; } - // Cmd+O / Ctrl+O: open links and attachments for the currently focused - // email. Handled before the input-focus/mode bails below so it works in - // the same focus states as Cmd+K/J/F. - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "o") { + // 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; diff --git a/tests/e2e/open-links-attachments.spec.ts b/tests/e2e/open-links-attachments.spec.ts index f9953446..c996b534 100644 --- a/tests/e2e/open-links-attachments.spec.ts +++ b/tests/e2e/open-links-attachments.spec.ts @@ -11,6 +11,7 @@ type TestEmail = { type TestStore = { getState: () => { emails: TestEmail[]; + isOpenLinksAttachmentsOpen: boolean; }; setState: (patch: Record) => void; }; @@ -64,6 +65,49 @@ test.describe("Open Links & Attachments palette", () => { 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__;