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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions src/renderer/components/SplitTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@ function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean {
}

interface TabProps {
splitId: string;
active: boolean;
onClick: () => void;
count?: number;
children: React.ReactNode;
}

function Tab({ active, onClick, count, children }: TabProps) {
function Tab({ splitId, active, onClick, count, children }: TabProps) {
return (
<button
type="button"
role="tab"
aria-selected={active}
data-split-tab-id={splitId}
onClick={onClick}
className={`
px-3 py-2 text-sm font-medium whitespace-nowrap
border-b-2 transition-colors focus:outline-none
border-b-2 rounded-sm transition-colors focus:outline-none
${
active
? "border-blue-500 dark:border-blue-400 text-blue-600 dark:text-blue-400"
Expand Down Expand Up @@ -134,16 +139,22 @@ function SplitTabsImpl() {

// Always show the tab bar — Priority, Other, Archive Ready always visible; All on the far right
return (
<div className="flex h-10 border-b border-gray-200 dark:border-gray-700 px-2 overflow-x-auto">
<div
role="tablist"
aria-label="Inbox split tabs"
className="flex h-10 border-b border-gray-200 dark:border-gray-700 px-2 overflow-x-auto"
>
{/* Primary tabs: Priority, Other */}
<Tab
splitId="__priority__"
active={currentSplitId === "__priority__"}
onClick={() => setCurrentSplitId("__priority__")}
count={counts.get("__priority__")}
>
Priority
</Tab>
<Tab
splitId="__other__"
active={currentSplitId === "__other__"}
onClick={() => setCurrentSplitId("__other__")}
count={counts.get("__other__")}
Expand All @@ -153,6 +164,7 @@ function SplitTabsImpl() {

{/* Middle tabs: Archive Ready, custom splits, conditional tabs */}
<Tab
splitId="__archive-ready__"
active={currentSplitId === "__archive-ready__"}
onClick={() => setCurrentSplitId("__archive-ready__")}
count={archiveReadyCount}
Expand All @@ -174,6 +186,7 @@ function SplitTabsImpl() {
{sortedSplits.map(({ split, displayName }) => (
<Tab
key={split.id}
splitId={split.id}
active={currentSplitId === split.id}
onClick={() => setCurrentSplitId(split.id)}
count={counts.get(split.id)}
Expand All @@ -186,6 +199,7 @@ function SplitTabsImpl() {
{/* Conditional virtual tabs */}
{draftsCount > 0 && (
<Tab
splitId="__drafts__"
active={currentSplitId === "__drafts__"}
onClick={() => setCurrentSplitId("__drafts__")}
count={draftsCount}
Expand All @@ -205,6 +219,7 @@ function SplitTabsImpl() {
)}
{snoozedCount > 0 && (
<Tab
splitId="__snoozed__"
active={currentSplitId === "__snoozed__"}
onClick={() => setCurrentSplitId("__snoozed__")}
count={snoozedCount}
Expand All @@ -225,6 +240,7 @@ function SplitTabsImpl() {

{/* All tab on the far right */}
<Tab
splitId="__all__"
active={currentSplitId === null}
onClick={() => setCurrentSplitId(null)}
count={counts.get(null)}
Expand Down
27 changes: 26 additions & 1 deletion src/renderer/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
} = state;

const isGmail = keyboardBindings === "gmail";
const isSuperhuman = keyboardBindings === "superhuman";

// Store actions are stable references — safe to read from getState()
const {
Expand Down Expand Up @@ -755,13 +756,27 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
};

// --- Helper: navigate to next/prev split tab ---
const cycleSplit = (direction: "next" | "prev") => {
const focusSplitTab = (splitId: string) => {
// Focus after the active-tab DOM/styling has committed for the store
// change. CSS.escape because custom split IDs are user-derived.
window.requestAnimationFrame(() => {
const tab = document.querySelector<HTMLButtonElement>(
`[data-split-tab-id="${CSS.escape(splitId)}"]`,
);
tab?.focus();
});
};

const cycleSplit = (direction: "next" | "prev", options: { focusTab?: boolean } = {}) => {
const ids = getOrderedSplitIds();
const currentIdx = ids.indexOf(currentSplitId ?? ALL_SENTINEL);
const step = direction === "next" ? 1 : -1;
const nextIdx = (currentIdx + step + ids.length) % ids.length;
const nextId = ids[nextIdx];
state.setCurrentSplitId(nextId === ALL_SENTINEL ? null : nextId);
if (options.focusTab) {
focusSplitTab(nextId);
}
};

// Normal mode shortcuts (single-key, no modifiers)
Expand Down Expand Up @@ -828,6 +843,13 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {})
}
break;

case "Tab":
if (isSuperhuman && !showSettings && mode === "normal") {
e.preventDefault();
cycleSplit(e.shiftKey ? "prev" : "next", { focusTab: true });
}
break;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
mickn marked this conversation as resolved.

case "o":
// "o" is Gmail's open conversation key (Gmail only, same as Enter)
if (!isGmail) break;
Expand Down Expand Up @@ -1153,6 +1175,9 @@ export function getKeyboardShortcuts(bindings: "superhuman" | "gmail") {
{ key: "k / ↑", description: "Move up" },
{ key: isGmail ? "o / Enter" : "Enter", description: "Open conversation" },
{ key: "Escape", description: "Back / Deselect" },
...(!isGmail
? [{ key: "Tab / Shift+Tab", description: "Next / previous section" }]
: []),
...(isGmail
? [
{ key: "n", description: "Next message in thread" },
Expand Down
140 changes: 139 additions & 1 deletion tests/e2e/inbox-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, Page, ElectronApplication } from "@playwright/test";
import { test, expect, Page, ElectronApplication, Locator } from "@playwright/test";
import { _electron as electron } from "@playwright/test";
import path from "path";
import { fileURLToPath } from "url";
Expand Down Expand Up @@ -71,11 +71,58 @@ async function getTabCount(page: Page, tabName: string): Promise<number> {
return match ? parseInt(match[1], 10) : 0;
}

async function expectNoVisibleBoxShadow(tab: Locator): Promise<void> {
const boxShadow = await tab.evaluate((element) => window.getComputedStyle(element).boxShadow);
const hasVisibleBoxShadow =
boxShadow !== "none" &&
!boxShadow.split(/,(?![^(]*\))/).every((layer) => {
const lengths = [...layer.matchAll(/-?\d+(?:\.\d+)?px/g)].map((match) =>
Number.parseFloat(match[0]),
);
const hasOnlyZeroLengths =
lengths.length > 0 && lengths.every((value) => Object.is(value, -0) || value === 0);
const hasTransparentColor = /rgba\([^)]*,\s*0(?:\.0+)?\s*\)/.test(layer);
return hasOnlyZeroLengths || hasTransparentColor;
});

expect(hasVisibleBoxShadow, `expected no visible box-shadow, got "${boxShadow}"`).toBe(false);
}

/** Count visible thread rows in the email list */
async function getVisibleThreadCount(page: Page): Promise<number> {
return page.locator("div[data-thread-id]").count();
}

async function setKeyboardBindings(page: Page, bindings: "superhuman" | "gmail"): Promise<void> {
await page.evaluate((value) => {
const store = (window as unknown as {
__ZUSTAND_STORE__?: {
getState: () => {
setKeyboardBindings: (bindings: "superhuman" | "gmail") => void;
};
};
}).__ZUSTAND_STORE__;

if (!store) throw new Error("Zustand store not exposed");
store.getState().setKeyboardBindings(value);
}, bindings);
}

async function getCurrentSplitId(page: Page): Promise<string | null> {
return page.evaluate(() => {
const store = (window as unknown as {
__ZUSTAND_STORE__?: {
getState: () => {
currentSplitId: string | null;
};
};
}).__ZUSTAND_STORE__;

if (!store) throw new Error("Zustand store not exposed");
return store.getState().currentSplitId;
});
}

test.describe("Inbox Tabs - Default and Ordering", () => {
test.describe.configure({ mode: "serial" });
let electronApp: ElectronApplication;
Expand Down Expand Up @@ -122,7 +169,98 @@ test.describe("Inbox Tabs - Default and Ordering", () => {
expect(labels[labels.length - 1]).toBe("All");
});

test("Tab key switches active split tabs in visible order", async () => {
const priorityTab = page.getByRole("tab", { name: /^Priority/ }).first();
const otherTab = page.getByRole("tab", { name: /^Other/ }).first();
const archiveReadyTab = page.getByRole("tab", { name: /Archive Ready/ }).first();

await setKeyboardBindings(page, "superhuman");
await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");

await page.keyboard.press("Tab");
await expect(otherTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toBeFocused();
await expectNoVisibleBoxShadow(otherTab);

await page.keyboard.press("Tab");
await expect(archiveReadyTab).toHaveAttribute("aria-selected", "true");
await expect(archiveReadyTab).toBeFocused();
await expectNoVisibleBoxShadow(archiveReadyTab);

await page.keyboard.press("Shift+Tab");
await expect(otherTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toBeFocused();

await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");
});

test("Gmail keyboard shortcuts keep Tab as native focus traversal", async () => {
const priorityTab = page.getByRole("tab", { name: /^Priority/ }).first();
const otherTab = page.getByRole("tab", { name: /^Other/ }).first();

await setKeyboardBindings(page, "gmail");
try {
await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toHaveAttribute("aria-selected", "false");
await expect(priorityTab).toBeFocused();

await page.keyboard.press("`");
await expect(otherTab).toHaveAttribute("aria-selected", "true");
await expect(priorityTab).toBeFocused();

await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toHaveAttribute("aria-selected", "false");

await page.keyboard.press("Tab");
await expect(priorityTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toHaveAttribute("aria-selected", "false");
} finally {
await setKeyboardBindings(page, "superhuman");
await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");
}
});

test("Tab key does not switch split tabs while compose controls are focused", async () => {
const priorityTab = page.getByRole("tab", { name: /^Priority/ }).first();
const otherTab = page.getByRole("tab", { name: /^Other/ }).first();

await setKeyboardBindings(page, "superhuman");
await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");
await expect(otherTab).toHaveAttribute("aria-selected", "false");

await page.keyboard.press("c");
await expect(page.getByText("New Message")).toBeVisible({ timeout: 5000 });

const discardButton = page.locator("button[title='Discard draft']").first();
try {
await expect(discardButton).toBeVisible();
await discardButton.focus();
await expect(discardButton).toBeFocused();

await page.keyboard.press("Tab");
await expect.poll(() => getCurrentSplitId(page)).toBe("__priority__");
} finally {
if (await discardButton.isVisible().catch(() => false)) {
await discardButton.click();
}
await expect(page.getByText("New Message")).not.toBeVisible({ timeout: 5000 });
}
});

test("Priority tab shows only priority emails (needsReply + done)", async () => {
const priorityTab = tabBar(page)
.locator("button")
.filter({ hasText: /^Priority/ })
.first();
await priorityTab.click();
await expect(priorityTab).toHaveAttribute("aria-selected", "true");

// Priority should be active by default — verify we see threads. The per-row
// pill was suppressed in the Priority tab by issue #143 (every row in this
// tab is implicitly priority, so the pill would be noise), so this test
Expand Down