Skip to content

Commit 0e7ad8e

Browse files
committed
feat(reader): toast feedback on yank; archive hotkeys-improvement
- Reader shows an auto-dismissing toast after a successful yank: "Message text copied to clipboard" (y) / "Message text with headers copied to clipboard" (Y); none on failure. - E2E: assert toast text for y/Y and toast auto-dismiss; repoint spec reference comments to the synced main specs. - Sync hotkeys-improvement deltas into openspec/specs (ui-hotkeys + new reader-message-actions incl. yank-clipboard-feedback) and archive the change under openspec/changes/archive/2026-06-19-hotkeys-improvement.
1 parent b0e80cf commit 0e7ad8e

12 files changed

Lines changed: 255 additions & 26 deletions

File tree

e2e/pages/MessagePage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ export class MessagePage {
138138
return this.page.getByTestId('headers-popover.container');
139139
}
140140

141+
/** The transient "copied to clipboard" toast shown after a yank. */
142+
yankToast(): Locator {
143+
return this.page.getByTestId('reader.yank-toast');
144+
}
145+
141146
/** `g f` — open the folder picker from the reader. */
142147
async gotoFolderPicker(): Promise<void> {
143148
await this.page.keyboard.press('g');

e2e/specs/hotkeys-list-leader.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,23 @@ async function openArchive(page: import('@playwright/test').Page): Promise<Mailb
2020
return mailbox;
2121
}
2222

23-
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g f opens the folder picker
23+
// openspec/specs/ui-hotkeys/spec.md: g f opens the folder picker
2424
test('g f opens the folder picker', async ({ page }) => {
2525
await openArchive(page);
2626
await page.keyboard.press('g');
2727
await page.keyboard.press('f');
2828
await expect(page.getByText('Open a folder')).toBeVisible();
2929
});
3030

31-
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g a opens the account picker
31+
// openspec/specs/ui-hotkeys/spec.md: g a opens the account picker
3232
test('g a opens the account picker', async ({ page }) => {
3333
await openArchive(page);
3434
await page.keyboard.press('g');
3535
await page.keyboard.press('a');
3636
await expect(page.getByText('Open a maildir')).toBeVisible();
3737
});
3838

39-
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g g jumps to top of list
39+
// openspec/specs/ui-hotkeys/spec.md: g g jumps to top of list
4040
test('g g jumps the selection to the top of the list', async ({ page }) => {
4141
const mailbox = await openArchive(page);
4242
// Move the selection to the bottom (keyboard), then g g returns it to 0.
@@ -46,7 +46,7 @@ test('g g jumps the selection to the top of the list', async ({ page }) => {
4646
await expect.poll(() => mailbox.selectedIndex()).toBe(0);
4747
});
4848

49-
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: G jumps to bottom of list
49+
// openspec/specs/ui-hotkeys/spec.md: G jumps to bottom of list
5050
test('G jumps the selection to the bottom of the page', async ({ page }) => {
5151
const mailbox = await openArchive(page);
5252
// Archive has more messages than a page; G selects the last rendered row.
@@ -55,7 +55,7 @@ test('G jumps the selection to the bottom of the page', async ({ page }) => {
5555
await expect.poll(() => mailbox.selectedIndex()).toBe(onPage - 1);
5656
});
5757

58-
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: removed follow-ups no longer navigate
58+
// openspec/specs/ui-hotkeys/spec.md: removed follow-ups no longer navigate
5959
test('removed leaders g i / g s / g d are no-ops', async ({ page }) => {
6060
await openArchive(page);
6161
for (const key of ['i', 's', 'd']) {

e2e/specs/reader-message-actions.spec.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ async function openMessage(
3333
return reader;
3434
}
3535

36-
// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: r opens compose addressed to sender, Re: subject, quoted body
36+
// openspec/specs/reader-message-actions/spec.md: r opens compose addressed to sender, Re: subject, quoted body
3737
test('r replies to sender with Re: subject and quoted body', async ({ page }) => {
3838
const reader = await openMessage(page, multi.subject);
3939
await reader.reply();
@@ -45,7 +45,7 @@ test('r replies to sender with Re: subject and quoted body', async ({ page }) =>
4545
expect(await compose.bodyValue()).toContain(`> ${firstBodyLine}`);
4646
});
4747

48-
// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: R populates To/Cc from participants, excludes own address
48+
// openspec/specs/reader-message-actions/spec.md: R populates To/Cc from participants, excludes own address
4949
test('R replies to all, Cc from participants, excluding the active account', async ({ page }) => {
5050
const reader = await openMessage(page, multi.subject);
5151
await reader.replyAll();
@@ -60,7 +60,7 @@ test('R replies to all, Cc from participants, excluding the active account', asy
6060
expect(cc).not.toContain(alice.address);
6161
});
6262

63-
// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: F forwards with empty To, Fwd: subject, headers + body
63+
// openspec/specs/reader-message-actions/spec.md: F forwards with empty To, Fwd: subject, headers + body
6464
test('F forwards with empty To, Fwd: subject, and forwarded headers + body', async ({ page }) => {
6565
const reader = await openMessage(page, multi.subject);
6666
await reader.forward();
@@ -75,7 +75,7 @@ test('F forwards with empty To, Fwd: subject, and forwarded headers + body', asy
7575
expect(body).toContain(firstBodyLine);
7676
});
7777

78-
// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: f still activates hint mode (distinct from F)
78+
// openspec/specs/reader-message-actions/spec.md: f still activates hint mode (distinct from F)
7979
test('f still activates hint mode and does not forward', async ({ page }) => {
8080
const reader = await openMessage(page, linky.subject);
8181
await reader.activateHints();
@@ -84,24 +84,34 @@ test('f still activates hint mode and does not forward', async ({ page }) => {
8484
await expect(page.getByTestId('compose.container')).toHaveCount(0);
8585
});
8686

87-
// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: y copies body only; Y copies headers + body
87+
// openspec/specs/reader-message-actions/spec.md: y copies body only; Y copies headers + body
8888
test('y copies the body only; Y copies headers and body', async ({ page }) => {
8989
const reader = await openMessage(page, multi.subject);
9090

9191
await reader.yankBody();
92+
await expect(reader.yankToast()).toHaveText('Message text copied to clipboard');
9293
const bodyOnly = await page.evaluate(() => navigator.clipboard.readText());
9394
expect(bodyOnly).toContain(firstBodyLine);
9495
expect(bodyOnly).not.toContain('From:');
9596

9697
await reader.yankHeaders();
98+
await expect(reader.yankToast()).toHaveText('Message text with headers copied to clipboard');
9799
const withHeaders = await page.evaluate(() => navigator.clipboard.readText());
98100
expect(withHeaders).toContain('From:');
99101
expect(withHeaders).toContain('To:');
100102
expect(withHeaders).toContain('Subject:');
101103
expect(withHeaders).toContain(firstBodyLine);
102104
});
103105

104-
// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: g h toggles the headers popover open/closed
106+
// openspec/specs/reader-message-actions/spec.md: yank toast auto-dismisses
107+
test('the yank toast appears then auto-dismisses', async ({ page }) => {
108+
const reader = await openMessage(page, multi.subject);
109+
await reader.yankBody();
110+
await expect(reader.yankToast()).toBeVisible();
111+
await expect(reader.yankToast()).toHaveCount(0, { timeout: 4000 });
112+
});
113+
114+
// openspec/specs/reader-message-actions/spec.md: g h toggles the headers popover open/closed
105115
test('g h toggles the headers menu open and closed', async ({ page }) => {
106116
const reader = await openMessage(page, multi.subject);
107117

@@ -112,14 +122,14 @@ test('g h toggles the headers menu open and closed', async ({ page }) => {
112122
await expect(reader.headersPopover()).toHaveCount(0);
113123
});
114124

115-
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g f opens the folder picker from the reader
125+
// openspec/specs/ui-hotkeys/spec.md: g f opens the folder picker from the reader
116126
test('g f opens the folder picker from the reader', async ({ page }) => {
117127
const reader = await openMessage(page, multi.subject);
118128
await reader.gotoFolderPicker();
119129
await expect(page.getByText('Open a folder')).toBeVisible();
120130
});
121131

122-
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g a opens the account picker from the reader
132+
// openspec/specs/ui-hotkeys/spec.md: g a opens the account picker from the reader
123133
test('g a opens the account picker from the reader', async ({ page }) => {
124134
const reader = await openMessage(page, multi.subject);
125135
await reader.gotoAccountPicker();

openspec/changes/hotkeys-improvement/.openspec.yaml renamed to openspec/changes/archive/2026-06-19-hotkeys-improvement/.openspec.yaml

File renamed without changes.

openspec/changes/hotkeys-improvement/design.md renamed to openspec/changes/archive/2026-06-19-hotkeys-improvement/design.md

File renamed without changes.

openspec/changes/hotkeys-improvement/proposal.md renamed to openspec/changes/archive/2026-06-19-hotkeys-improvement/proposal.md

File renamed without changes.

openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md renamed to openspec/changes/archive/2026-06-19-hotkeys-improvement/specs/reader-message-actions/spec.md

File renamed without changes.

openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md renamed to openspec/changes/archive/2026-06-19-hotkeys-improvement/specs/ui-hotkeys/spec.md

File renamed without changes.

openspec/changes/hotkeys-improvement/tasks.md renamed to openspec/changes/archive/2026-06-19-hotkeys-improvement/tasks.md

File renamed without changes.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# reader-message-actions
2+
3+
## Purpose
4+
5+
Message-level actions available from the reader scope: replying to the sender,
6+
replying to all participants, forwarding, yanking the body (with or without headers)
7+
to the clipboard, and toggling the headers menu. The keyboard bindings that trigger
8+
these actions are declared in the `ui-hotkeys` capability; this capability defines the
9+
behavior of each action.
10+
11+
---
12+
## Requirements
13+
### Requirement: Reply to sender
14+
The reader SHALL provide an `r` action that opens the compose screen prefilled as a
15+
reply to the open message's sender. The `To` field SHALL be set to the original
16+
message's `From` address. The `Subject` SHALL be the original subject prefixed with
17+
`Re: `, not duplicated if the subject already begins with `Re:` (case-insensitive). The
18+
compose body SHALL contain the original message body quoted, with each line prefixed by
19+
`> ` (greater-than then a single space). `r` SHALL be active only when the reader is the
20+
active scope and focus is not in a text input.
21+
22+
#### Scenario: r opens compose addressed to the sender
23+
- **WHEN** the reader is open on a message and the user presses `r`
24+
- **THEN** the compose screen opens with the `To` field set to the original message's `From` address
25+
26+
#### Scenario: Original body is quoted with "> " prefix
27+
- **WHEN** the user presses `r` in the reader
28+
- **THEN** the compose body contains the original message text with each line prefixed by `> `
29+
30+
#### Scenario: Subject gets a single Re: prefix
31+
- **WHEN** the user replies to a message whose subject is `Hello`
32+
- **THEN** the compose subject is `Re: Hello`
33+
34+
#### Scenario: Re: is not duplicated
35+
- **WHEN** the user replies to a message whose subject is already `Re: Hello`
36+
- **THEN** the compose subject remains `Re: Hello` (no `Re: Re:`)
37+
38+
#### Scenario: r suppressed while typing
39+
- **WHEN** focus is in an `input` or `textarea` and the user presses `r`
40+
- **THEN** the character is typed normally and no reply is started
41+
42+
### Requirement: Reply to all
43+
The reader SHALL provide an `R` (Shift+r) action that opens the compose screen as a
44+
reply to every participant of the open message. The `To` field SHALL contain the
45+
original `From` address; the `Cc` field SHALL contain the union of the original `To`
46+
and `Cc` recipients. The active account's own address SHALL be excluded from both
47+
fields. Subject prefixing and body quoting SHALL follow the same rules as `r`.
48+
49+
#### Scenario: R populates To and Cc from all participants
50+
- **WHEN** the reader is open on a message with multiple recipients and the user presses `R`
51+
- **THEN** the compose `To` is the original `From` and the `Cc` contains the other original `To`/`Cc` recipients
52+
53+
#### Scenario: Own address excluded from reply-all
54+
- **WHEN** the user presses `R` on a message that also listed the active account's own address as a recipient
55+
- **THEN** the active account's address does not appear in the `To` or `Cc` fields
56+
57+
#### Scenario: Reply-all quotes the original body
58+
- **WHEN** the user presses `R` in the reader
59+
- **THEN** the compose body contains the original message text with each line prefixed by `> `
60+
61+
### Requirement: Forward message
62+
The reader SHALL provide an `F` (Shift+f) action that opens the compose screen to
63+
forward the open message. The `To` field SHALL be empty, the `Subject` SHALL be the
64+
original subject prefixed with `Fwd: ` (not duplicated), and the body SHALL contain the
65+
forwarded original message including its `From`, `To`, `Subject`, and `Date` headers
66+
followed by the original body. `F` SHALL NOT interfere with `f`, which remains hint mode.
67+
68+
#### Scenario: F opens compose to forward
69+
- **WHEN** the reader is open and the user presses `F`
70+
- **THEN** the compose screen opens with an empty `To` and the subject prefixed with `Fwd: `
71+
72+
#### Scenario: Forwarded body includes original headers
73+
- **WHEN** the user presses `F` in the reader
74+
- **THEN** the compose body includes the original message's `From`, `To`, `Subject`, and `Date` followed by the original body
75+
76+
#### Scenario: f still activates hint mode
77+
- **WHEN** the reader is open (text/simple mode) and the user presses `f`
78+
- **THEN** hint mode activates and no forward is started
79+
80+
### Requirement: Yank message body
81+
The reader SHALL provide a `y` action that copies the open message's plain-text body to
82+
the system clipboard. The copied content SHALL be the body only, without headers.
83+
84+
#### Scenario: y copies the body to the clipboard
85+
- **WHEN** the reader is open and the user presses `y`
86+
- **THEN** the system clipboard contains the message's plain-text body and no headers
87+
88+
### Requirement: Yank message with headers
89+
The reader SHALL provide a `Y` (Shift+y) action that copies the open message's common
90+
headers followed by its body to the system clipboard. The headers SHALL include at
91+
least `From`, `To`, and `Subject`, plus `Date` and `Cc` when present, each on its own
92+
line, followed by a blank line and then the plain-text body.
93+
94+
#### Scenario: Y copies headers and body
95+
- **WHEN** the reader is open and the user presses `Y`
96+
- **THEN** the system clipboard contains `From`, `To`, and `Subject` lines (and `Date`/`Cc` when present) followed by a blank line and the message body
97+
98+
### Requirement: Yank clipboard feedback
99+
After a successful yank, the reader SHALL show a transient toast notification confirming
100+
the copy. `y` SHALL show `Message text copied to clipboard`; `Y` SHALL show
101+
`Message text with headers copied to clipboard`. The toast SHALL dismiss itself after a
102+
short delay. No toast SHALL be shown if the clipboard write fails (e.g. insecure context
103+
or denied permission).
104+
105+
#### Scenario: y shows a body-copied toast
106+
- **WHEN** the reader is open and the user presses `y`
107+
- **THEN** a toast reading `Message text copied to clipboard` appears and then auto-dismisses
108+
109+
#### Scenario: Y shows a headers-copied toast
110+
- **WHEN** the reader is open and the user presses `Y`
111+
- **THEN** a toast reading `Message text with headers copied to clipboard` appears and then auto-dismisses
112+
113+
### Requirement: Headers menu toggle
114+
The reader SHALL provide a `g h` leader sequence that toggles the headers menu (the
115+
`HeadersPopover`) showing the message's full header set. Pressing `g h` again or
116+
`Escape` SHALL close it.
117+
118+
#### Scenario: g h opens the headers menu
119+
- **WHEN** the reader is open and the user presses `g` then `h` within the leader timeout
120+
- **THEN** the headers menu opens showing the message's full headers
121+
122+
#### Scenario: g h closes an open headers menu
123+
- **WHEN** the headers menu is open and the user presses `g` then `h`
124+
- **THEN** the headers menu closes

0 commit comments

Comments
 (0)