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 */} +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: "LinksSame target again: https://dup.example.com/path?x=1#top
+ + + + +Unsupported bare domain: www.example.com/no-protocol
+ + + `, + 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