eleven / messenger Public

Keyboard shortcuts cmd-T and cmd-R (#109)

* web: add keyboard shortcuts cmd-T and cmd-R for quick message actions

Add cmd-T to open the message menu on the latest message, with number keys
(1-6) to send reactions in order. Add cmd-R to quickly start replying to the
most recent message.

Only active when the composer is empty to avoid interfering with normal typing.

* web: cmd-T targets last message from others, not your own

* web: fix cmd-T number key reactions, cmd-R targets all messages

* changelog: keyboard shortcuts cmd-T and cmd-R

* web: improve number key detection for message sheet reactions

* web: add console logging for keyboard shortcut debugging

* web: add ESC key handling for cmd-T menu and reply cancellation
James Adam 2fb48e4f0d648dee6bbc964996a0da7fb0a77c4c parent b7244cc
2 files changed, +106 −3
  • changelog/2026-07-03-keyboard-shortcuts.md +5 −0
  • web/static/chat.js +101 −3
diff --git a/changelog/2026-07-03-keyboard-shortcuts.md b/changelog/2026-07-03-keyboard-shortcuts.md
new file mode 100644
index 0000000..55cac63
--- /dev/null
+++ b/changelog/2026-07-03-keyboard-shortcuts.md
@@ -0,0 +1,5 @@
+# Keyboard shortcuts
+
+**Cmd+T** opens the message menu on the latest message from someone else — press 1-6 to send a reaction.
+
+**Cmd+R** quickly starts a reply to the most recent message.
diff --git a/web/static/chat.js b/web/static/chat.js
index 0b56f82..d8bd06f 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -606,7 +606,7 @@ function agentTag(muted) {
t.textContent = "BOT";
t.title = "This is an agent (bot), not a person";
t.style.cssText =
- "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;" +
+ "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;transform:translateY(-1px);" +
(muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;");
return t;
}
@@ -1151,6 +1151,79 @@ function renderApp() {
if (isCoarse) text.focus(); // keep the on-screen keyboard up after sending
};
+ // Keyboard shortcuts: cmd-T opens message menu, cmd-R starts quick reply.
+ // When the menu is open, number keys trigger reactions or actions.
+ let reactionSheetKeyListener = null;
+
+ function getLatestMessage(othersOnly = false) {
+ const feed = document.getElementById("feed");
+ if (!feed) return null;
+ const messages = feed.querySelectorAll(".msg");
+ if (!messages.length) return null;
+ // Find the latest message, optionally filtering to exclude own messages
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msgId = messages[i].dataset.id;
+ if (!msgId) continue;
+ if (othersOnly && msgData[msgId]?.mine) continue;
+ return { id: msgId, el: messages[i] };
+ }
+ return null;
+ }
+
+ function attachReactionSheetKeyHandler(msgId, wrap) {
+ // Wait a tick for the sheet to be in the DOM
+ setTimeout(() => {
+ const sheet = document.querySelector(".msg-sheet");
+ if (!sheet) return;
+
+ if (reactionSheetKeyListener) {
+ document.removeEventListener("keydown", reactionSheetKeyListener, true);
+ reactionSheetKeyListener = null;
+ }
+
+ reactionSheetKeyListener = (e) => {
+ const num = parseInt(e.key, 10);
+ if (isNaN(num) || num < 1 || num > 6) return;
+
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ const emoji = REACTIONS[num - 1];
+ if (emoji) {
+ toggleReaction(msgId, emoji);
+ const currentSheet = document.querySelector(".msg-sheet");
+ if (currentSheet) currentSheet.remove();
+ wrap.style.visibility = "";
+ document.removeEventListener("keydown", reactionSheetKeyListener, true);
+ reactionSheetKeyListener = null;
+ }
+ };
+
+ // Attach to document with capture, but also to the sheet itself
+ document.addEventListener("keydown", reactionSheetKeyListener, true);
+ sheet.addEventListener("keydown", reactionSheetKeyListener);
+ }, 0);
+ }
+
+ document.addEventListener("keydown", (e) => {
+ // Cmd/Ctrl+T: open message menu on latest message from someone else
+ if ((e.metaKey || e.ctrlKey) && e.key === "t" && !text.value) {
+ e.preventDefault();
+ const msg = getLatestMessage(true); // othersOnly=true
+ if (msg) {
+ openMessageSheet(msg.id, msg.el);
+ attachReactionSheetKeyHandler(msg.id, msg.el);
+ }
+ }
+ // Cmd/Ctrl+R: start reply to latest message (including own)
+ if ((e.metaKey || e.ctrlKey) && e.key === "r" && !text.value) {
+ e.preventDefault();
+ const msg = getLatestMessage(false); // othersOnly=false (all messages)
+ if (msg && msgData[msg.id]?.replyMeta) {
+ startReply(msgData[msg.id].replyMeta);
+ }
+ }
+ });
+
// Attachments: pick / drag-drop / paste an image into the open thread.
const fileInput = document.getElementById("file-input");
document.getElementById("attach-btn").onclick = () => fileInput.click();
@@ -2071,7 +2144,7 @@ function toggleReaction(msgId, emoji) {
const mine = myReactions.has(key);
if (mine) myReactions.delete(key);
else myReactions.add(key);
- ws.send(JSON.stringify({ type: mine ? "unreact" : "react", thread_id: activeId, message_id: msgId, emoji }));
+ ws.send(JSON.stringify({ type: mine ? "unreact" : "react", thread_id: activeId, message_id: Number(msgId), emoji }));
}
// Open the emoji picker over a message; press-and-hold (desktop) or double-tap
@@ -2280,12 +2353,22 @@ function openMessageSheet(msgId, wrap) {
wrap.style.visibility = "";
backdrop.remove();
document.removeEventListener("pointerdown", onDoc, true);
+ document.removeEventListener("keydown", onEsc);
}
const onDoc = (e) => {
if (!reactions.contains(e.target) && !menu.contains(e.target)) close();
};
+ const onEsc = (e) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ close();
+ }
+ };
// Deferred so the press that opened the sheet doesn't immediately close it.
- setTimeout(() => document.addEventListener("pointerdown", onDoc, true), 0);
+ setTimeout(() => {
+ document.addEventListener("pointerdown", onDoc, true);
+ document.addEventListener("keydown", onEsc);
+ }, 0);
}
// Double-tap (touch) → a quick emoji reactions bar. Just the emojis: the actions
@@ -2582,9 +2665,24 @@ function startReply(meta) {
// embed mode) and focuses its own composer.
if (embed) nativePost({ replying: { name: meta.name || "", snippet: meta.snippet || "" } });
else document.getElementById("text").focus();
+ // ESC key cancels reply but preserves text
+ const replyEscHandler = (e) => {
+ if (e.key === "Escape" && replyingTo) {
+ e.preventDefault();
+ cancelReply();
+ }
+ };
+ document.addEventListener("keydown", replyEscHandler);
+ // Store handler so it can be removed if a new reply starts or edit begins
+ replyEscHandler._bound = true;
+ cancelReply._escHandler = replyEscHandler;
}
function cancelReply() {
+ if (cancelReply._escHandler) {
+ document.removeEventListener("keydown", cancelReply._escHandler);
+ cancelReply._escHandler = null;
+ }
replyingTo = null;
const box = document.getElementById("composer-reply");
if (box) {