diff --git a/CHANGELOG.md b/CHANGELOG.md
index 560d709..381fe91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 0.1.28
+
+- Reply to or forward any expanded message in a conversation while preserving the existing
+ conversation-level actions after the final message.
+- Replace the hidden-message divider label with a counted two-arrow control that points outward to
+ expand and inward to collapse.
+
## 0.1.27
- Show a private Drafts destination in desktop and mobile navigation only when the signed-in user
diff --git a/app/features/messages/conversation-messages.tsx b/app/features/messages/conversation-messages.tsx
index 503d578..ed771c5 100644
--- a/app/features/messages/conversation-messages.tsx
+++ b/app/features/messages/conversation-messages.tsx
@@ -1,4 +1,4 @@
-import { Download, MessagesSquare } from "lucide-react";
+import { ArrowDown, ArrowUp, Download, Forward, Reply } from "lucide-react";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
@@ -11,10 +11,12 @@ import type { MessageDetail } from "./types";
export function ConversationMessages({
compact = false,
- messages
+ messages,
+ onCompose
}: {
compact?: boolean;
messages: MessageDetail[];
+ onCompose?: (message: MessageDetail, mode: "reply" | "forward") => void;
}): React.ReactElement {
const hiddenCount = Math.max(0, messages.length - 2);
const threadFingerprint = messages.map((message) => message.id).join(":");
@@ -95,6 +97,36 @@ export function ConversationMessages({
>
) : null}
+ {onCompose ? (
+
+ ) : null}
);
}
@@ -110,19 +142,39 @@ function ThreadMessagesDivider({
onToggle: () => void;
}): React.ReactElement {
const noun = count === 1 ? "message" : "messages";
+ const label = expanded ? `Collapse ${count} earlier ${noun}` : `Expand ${count} earlier ${noun}`;
return (
diff --git a/app/features/messages/message-detail.tsx b/app/features/messages/message-detail.tsx
index 6bbcb24..82c5690 100644
--- a/app/features/messages/message-detail.tsx
+++ b/app/features/messages/message-detail.tsx
@@ -23,6 +23,13 @@ type MessageDetailProps = {
onSent: () => void;
};
+type ThreadComposeMode = Extract;
+
+type ThreadComposeState = {
+ message: MessageDetailType;
+ mode: ThreadComposeMode;
+};
+
export function MessageDetail({
error = null,
isLoading = false,
@@ -34,10 +41,7 @@ export function MessageDetail({
onDraftsChange,
onSent
}: MessageDetailProps): React.ReactElement {
- const [composeMode, setComposeMode] = React.useState | null>(null);
+ const [composeState, setComposeState] = React.useState(null);
if (isLoading) {
return ;
@@ -55,7 +59,6 @@ export function MessageDetail({
selected.direction === "inbound"
? selected
: ([...messages].reverse().find((message) => message.direction === "inbound") ?? selected);
- const composeTarget = composeMode === "reply" ? replyTarget : selected;
const isUnread = messages.some(
(message) => message.direction === "inbound" && message.readAt === null
);
@@ -101,9 +104,12 @@ export function MessageDetail({
-
+
setComposeState({ message, mode })}
+ />
- {composeMode ? (
+ {composeState ? (
@@ -112,15 +118,16 @@ export function MessageDetail({
}
>
}
{...(onDraftsChange ? { onDraftsChange } : {})}
onOpenChange={(nextOpen) => {
- if (!nextOpen) setComposeMode(null);
+ if (!nextOpen) setComposeState(null);
}}
onSent={onSent}
/>
@@ -132,7 +139,7 @@ export function MessageDetail({
size="lg"
type="button"
variant="outline"
- onClick={() => setComposeMode("reply")}
+ onClick={() => setComposeState({ message: replyTarget, mode: "reply" })}
>
Reply
@@ -142,7 +149,7 @@ export function MessageDetail({
size="lg"
type="button"
variant="outline"
- onClick={() => setComposeMode("forward")}
+ onClick={() => setComposeState({ message: selected, mode: "forward" })}
>
Forward
diff --git a/package.json b/package.json
index 131b697..35c6ed1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hqbase",
- "version": "0.1.27",
+ "version": "0.1.28",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
diff --git a/test/unit/app/messages/conversation-message-actions.test.tsx b/test/unit/app/messages/conversation-message-actions.test.tsx
new file mode 100644
index 0000000..1b68562
--- /dev/null
+++ b/test/unit/app/messages/conversation-message-actions.test.tsx
@@ -0,0 +1,102 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi } from "vitest";
+
+import { ConversationMessages } from "@/features/messages/conversation-messages";
+import type { MessageDetail } from "@/features/messages/types";
+import { flushHookEffects, renderComponent } from "../render-hook";
+
+const firstMessage: MessageDetail = {
+ id: "msg_1",
+ threadId: "thr_1",
+ mailboxId: "mbx_1",
+ direction: "inbound",
+ folder: "inbox",
+ fromAddress: "customer@example.com",
+ to: ["support@example.com"],
+ cc: [],
+ bcc: [],
+ deliveredToAddress: "support@example.com",
+ subject: "Account access",
+ snippet: "I cannot sign in",
+ textBody: "I cannot sign in.",
+ htmlAvailable: false,
+ messageId: "",
+ inReplyTo: null,
+ references: [],
+ attachments: [],
+ receivedAt: "2026-07-27T14:00:00.000Z",
+ sentAt: null,
+ readAt: null,
+ starredAt: null,
+ hasAttachments: false,
+ createdAt: "2026-07-27T14:00:00.000Z"
+};
+
+const secondMessage: MessageDetail = {
+ ...firstMessage,
+ id: "msg_2",
+ direction: "outbound",
+ folder: "sent",
+ fromAddress: "support@example.com",
+ to: ["customer@example.com"],
+ textBody: "We can help.",
+ snippet: "We can help",
+ messageId: "",
+ inReplyTo: "",
+ references: [""],
+ receivedAt: null,
+ sentAt: "2026-07-27T14:05:00.000Z",
+ readAt: "2026-07-27T14:05:00.000Z",
+ createdAt: "2026-07-27T14:05:00.000Z"
+};
+
+describe("conversation message actions", () => {
+ it("targets the exact message selected for Reply or Forward", async () => {
+ const onCompose = vi.fn();
+ const view = await renderComponent(
+
+ );
+
+ const firstReply = view.container.querySelector(
+ '[data-compose-action="reply"][data-compose-message-id="msg_1"]'
+ );
+ const secondForward = view.container.querySelector(
+ '[data-compose-action="forward"][data-compose-message-id="msg_2"]'
+ );
+ await flushHookEffects(() => firstReply?.click());
+ await flushHookEffects(() => secondForward?.click());
+
+ expect(onCompose).toHaveBeenNthCalledWith(1, firstMessage, "reply");
+ expect(onCompose).toHaveBeenNthCalledWith(2, secondMessage, "forward");
+
+ await view.unmount();
+ });
+
+ it("switches the counted thread control between outward and inward arrows", async () => {
+ const messages = Array.from({ length: 4 }, (_, index) => ({
+ ...firstMessage,
+ id: `msg_${index + 1}`,
+ textBody: `Message body ${index + 1}`
+ }));
+ const view = await renderComponent();
+ const control = view.container.querySelector(
+ "[data-thread-disclosure-state]"
+ );
+
+ expect(control?.getAttribute("aria-label")).toBe("Expand 2 earlier messages");
+ expect(control?.querySelector('[data-thread-arrow="top-outward"]')).not.toBeNull();
+ expect(control?.querySelector('[data-thread-arrow="bottom-outward"]')).not.toBeNull();
+ expect(view.container.textContent).not.toContain("Message body 2");
+
+ await flushHookEffects(() => control?.click());
+
+ expect(control?.getAttribute("aria-label")).toBe("Collapse 2 earlier messages");
+ expect(control?.getAttribute("data-thread-disclosure-state")).toBe("expanded");
+ expect(control?.querySelector('[data-thread-arrow="top-inward"]')).not.toBeNull();
+ expect(control?.querySelector('[data-thread-arrow="bottom-inward"]')).not.toBeNull();
+ expect(view.container.textContent).toContain("Message body 2");
+ expect(view.container.textContent).toContain("Message body 3");
+
+ await view.unmount();
+ });
+});
diff --git a/test/unit/app/messages/conversation-reader.test.tsx b/test/unit/app/messages/conversation-reader.test.tsx
index 10796dd..d8acf9b 100644
--- a/test/unit/app/messages/conversation-reader.test.tsx
+++ b/test/unit/app/messages/conversation-reader.test.tsx
@@ -64,7 +64,7 @@ const conversation: ConversationSummary = {
};
describe("conversation reader", () => {
- it("renders the complete thread and keeps Reply and Forward at the bottom", () => {
+ it("renders Reply and Forward for every message and keeps the large final actions", () => {
const html = renderToStaticMarkup(
{
expect(html.indexOf("I cannot sign in.")).toBeLessThan(html.indexOf("We can help."));
expect(html.indexOf("We can help.")).toBeLessThan(html.lastIndexOf(">Reply<"));
- expect(html).toContain(">Forward<");
+ expect(html.match(/>ReplyForward {
expect(html).not.toContain("Message body 4");
expect(html).not.toContain("Message body 5");
expect(html).toContain("Message body 6");
- expect(html).toContain("4 earlier messages");
+ expect(html).toContain('aria-label="Expand 4 earlier messages"');
expect(html).toContain('aria-expanded="false"');
+ expect(html).toContain('data-thread-disclosure-state="collapsed"');
+ expect(html).toContain('data-thread-arrow="top-outward"');
+ expect(html).toContain('data-thread-arrow="bottom-outward"');
});
});
diff --git a/test/unit/app/render-hook.tsx b/test/unit/app/render-hook.tsx
index 04927bc..b925868 100644
--- a/test/unit/app/render-hook.tsx
+++ b/test/unit/app/render-hook.tsx
@@ -31,6 +31,19 @@ export async function renderHook(
};
}
+export async function renderComponent(content: ReactNode): Promise<{
+ container: HTMLDivElement;
+ unmount: () => Promise;
+}> {
+ const container = document.createElement("div");
+ const root = ReactDOM.createRoot(container);
+ await render(root, content);
+ return {
+ container,
+ unmount: () => render(root, null)
+ };
+}
+
async function render(root: Root, content: ReactNode): Promise {
await act(async () => {
root.render(content);
diff --git a/test/unit/worker/features/updates/service.test.ts b/test/unit/worker/features/updates/service.test.ts
index 110b3d0..e6aa781 100644
--- a/test/unit/worker/features/updates/service.test.ts
+++ b/test/unit/worker/features/updates/service.test.ts
@@ -43,9 +43,10 @@ describe("HQBase updates", () => {
expect(compareVersions("0.2.0", "0.1.9")).toBeGreaterThan(0);
});
it("rejects a tampered manifest", async () => {
+ const replacement = envelope.signature.startsWith("A") ? "B" : "A";
await expect(
getUpdateStatus({ HQBASE_RELEASE_PUBLIC_KEY: publicKeyBase64 } as WorkerEnv, async () =>
- Response.json({ ...envelope, signature: `A${envelope.signature.slice(1)}` })
+ Response.json({ ...envelope, signature: `${replacement}${envelope.signature.slice(1)}` })
)
).rejects.toThrow("signature");
});