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
149 changes: 149 additions & 0 deletions desktop/frontend/src/components/ShortcutsCheatsheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useEffect, useRef } from "react";

// ShortcutsCheatsheet is the "?" overlay — a single column of every
// keyboard shortcut the desktop app honors. The list is intentionally
// declarative (an array of [keys, description] tuples) so adding a
// shortcut is a one-line change here. The component is mounted by App and
// toggled by a "?" keypress on the topbar; it also opens via the menu in
// the topbar's chip cluster.
//
// Focus is trapped while open (the cheatsheet is a small dialog), Esc
// closes it, and focus returns to the element that opened it. We use a
// role="dialog" + aria-modal="true" + aria-labelledby so screen readers
// announce the title and don't expose the page behind.
//
// The cheatsheet is NOT localized (shortcut keys are universal); the
// descriptions are. Both en.ts and zh.ts have their own table that
// maps shortcut-id -> description, looked up via the prop's `t` helper.
export interface ShortcutEntry {
// id is a stable, dotted key. Examples: "composer.send",
// "composer.cycleMode", "transcript.cancel", "global.history".
id: string;
// keys is a stringified combo the user types, e.g. "Enter",
// "Shift+Tab", "Ctrl+K", "⌘K", "⌥↑". It's rendered verbatim in a
// <kbd> cluster; we don't try to normalize Mac vs. PC — the platform
// hint lives in the right column ("Mac" / "Win/Linux").
keys: string[];
// platform is "mac" | "win" | "all". "all" means the shortcut is the
// same on every platform and the platform column is hidden. The
// current OS is detected once at module load (see os() below); users
// on Windows don't see Mac-only shortcuts and vice versa.
platform: "mac" | "win" | "all";
}

export function ShortcutsCheatsheet({
open,
onClose,
entries,
t,
}: {
open: boolean;
onClose: () => void;
entries: ShortcutEntry[];
// The translator is typed loosely because the cheatsheet uses dotted
// shortcut keys ("shortcuts.desc.composer.send") that aren't in the
// DictKey union — they're deliberately dynamic so adding a new
// shortcut is a one-line change without touching the locale types.
t: (id: string) => string;
}) {
// Restore focus to the opener on close. We snapshot the active element
// when open flips true, then on the next focus return it. Doing it in a
// layout effect would fire on every render; an effect is fine because
// we only act on the open->close transition.
const triggerRef = useRef<HTMLElement | null>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement as HTMLElement | null;
// Move focus into the dialog so screen readers announce it and
// keyboard nav starts inside.
requestAnimationFrame(() => closeRef.current?.focus());
} else if (triggerRef.current && triggerRef.current.isConnected) {
triggerRef.current.focus();
triggerRef.current = null;
}
}, [open]);

// Esc closes. We don't trap Tab (the cheatsheet is small and the only
// focusable is the close button) — adding a roving trap would be
// overkill.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);

if (!open) return null;

const host = os();
const visible = entries.filter((e) => e.platform === "all" || e.platform === host);

// Group consecutive entries by their leading id segment so the cheatsheet
// reads as "Composer", "Transcript", "Global" sections. The id is
// dotted ("composer.send") so the first segment is the section.
const grouped: { section: string; items: ShortcutEntry[] }[] = [];
for (const e of visible) {
const section = e.id.split(".")[0];
const last = grouped[grouped.length - 1];
if (last && last.section === section) last.items.push(e);
else grouped.push({ section, items: [e] });
}

return (
<div className="drawer-backdrop" onClick={onClose} role="presentation">
<aside
className="drawer"
role="dialog"
aria-modal="true"
aria-labelledby="cheatsheet-title"
onClick={(e) => e.stopPropagation()}
>
<header className="drawer__head">
<div id="cheatsheet-title" className="drawer__title">
{t("shortcuts.title")}
</div>
<button ref={closeRef} className="chip" onClick={onClose} aria-label={t("shortcuts.close")} type="button">
</button>
</header>
<div className="drawer__body cheatsheet">
{grouped.map((g) => (
<section className="cheatsheet__section" key={g.section}>
<div className="cheatsheet__section-title">{t("shortcuts.section." + g.section)}</div>
<ul className="cheatsheet__list">
{g.items.map((it) => (
<li className="cheatsheet__row" key={it.id}>
<span className="cheatsheet__keys">
{it.keys.map((k, i) => (
<kbd key={i} className="cheatsheet__kbd">
{k}
</kbd>
))}
</span>
<span className="cheatsheet__desc">{t("shortcuts.desc." + it.id)}</span>
</li>
))}
</ul>
</section>
))}
</div>
</aside>
</div>
);
}

function os(): "mac" | "win" {
if (typeof navigator === "undefined") return "win";
// The UA sniff is the same pattern the rest of the desktop app uses
// (see StatusBar's platform switch). We don't need perfect
// identification — Linux + Windows collapse to "win" and the cheatsheet
// shows PC-style shortcuts; macOS users get ⌘/⌥/⇧.
const ua = navigator.userAgent || "";
return /Mac|iPhone|iPad/.test(ua) ? "mac" : "win";
}
16 changes: 16 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,22 @@ export const en = {
"todo.title": "To-dos",
"todo.dismiss": "Dismiss the task list",

// shortcuts cheatsheet (press ?)
"shortcuts.title": "Keyboard shortcuts",
"shortcuts.close": "Close shortcuts",
"shortcuts.open": "Show keyboard shortcuts",
"shortcuts.section.composer": "Composer",
"shortcuts.section.transcript": "Transcript",
"shortcuts.section.global": "Global",
"shortcuts.desc.composer.send": "Send prompt",
"shortcuts.desc.composer.newline": "Newline (Shift+Enter)",
"shortcuts.desc.composer.cycleMode": "Cycle mode (normal → plan → YOLO)",
"shortcuts.desc.composer.historyPrev": "Previous prompt",
"shortcuts.desc.composer.historyNext": "Next prompt",
"shortcuts.desc.composer.cancel": "Stop the current turn",
"shortcuts.desc.transcript.jumpBottom": "Jump to bottom",
"shortcuts.desc.global.cheatsheet": "Show this cheatsheet",

// slash menu tags
"slash.project": "project",
"slash.mcp": "mcp",
Expand Down
16 changes: 16 additions & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,22 @@ export const zh: Record<DictKey, string> = {
"todo.title": "待办",
"todo.dismiss": "关闭待办列表",

// 快捷键速查表(按 ? 打开)
"shortcuts.title": "键盘快捷键",
"shortcuts.close": "关闭快捷键速查表",
"shortcuts.open": "显示键盘快捷键",
"shortcuts.section.composer": "输入框",
"shortcuts.section.transcript": "会话",
"shortcuts.section.global": "全局",
"shortcuts.desc.composer.send": "发送消息",
"shortcuts.desc.composer.newline": "换行(Shift+Enter)",
"shortcuts.desc.composer.cycleMode": "切换模式(normal → plan → YOLO)",
"shortcuts.desc.composer.historyPrev": "上一条已发送的消息",
"shortcuts.desc.composer.historyNext": "下一条已发送的消息",
"shortcuts.desc.composer.cancel": "停止当前回合",
"shortcuts.desc.transcript.jumpBottom": "跳到底部",
"shortcuts.desc.global.cheatsheet": "显示本速查表",

// 斜杠菜单标签
"slash.project": "项目",
"slash.mcp": "mcp",
Expand Down
103 changes: 103 additions & 0 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,109 @@ body {
overflow: hidden;
}

/* Accessibility: keyboard focus rings on every interactive element. The ring
is rendered as a 2px outline in the accent color with a 2px transparent
gap (the standard "double ring" pattern), so it's visible on both light
and dark themes. :focus-visible is the right pseudo-class — :focus would
paint the ring on every click, which the OS / WebKitGTK already draws a
focus rectangle for, doubling up the visual noise. Buttons that are
:disabled skip the ring entirely (it's not actionable). */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 5px;
}
:disabled:focus-visible {
outline: none;
}

/* Skip-to-composer link: a visually-hidden link that becomes visible when
it receives keyboard focus (Tab from the topbar lands on it first).
Pressing Enter moves focus to the textarea so a screen-reader or
keyboard-only user can start typing without tabbing through every
topbar chip. */
.skip-link {
position: absolute;
top: -100px;
left: 12px;
z-index: 1000;
padding: 8px 14px;
background: var(--accent);
color: var(--accent-fg);
border-radius: 6px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: top 0.12s;
}
.skip-link:focus {
top: 12px;
}

/* Cheatsheet: declarative key/desc rows. Each row is a flex line; the keys
get a fixed min-width on the left so the descriptions align across rows
(eye doesn't have to re-find the column on every line). The kbd cluster
matches the existing welcome kbd style. */
.cheatsheet {
padding: 4px 4px 16px;
}
.cheatsheet__section + .cheatsheet__section {
margin-top: 18px;
}
.cheatsheet__section-title {
font-size: 11.5px;
font-weight: 600;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0 8px 6px;
}
.cheatsheet__list {
list-style: none;
margin: 0;
padding: 0;
}
.cheatsheet__row {
display: flex;
align-items: center;
gap: 12px;
padding: 7px 8px;
border-radius: 5px;
}
.cheatsheet__row:hover {
background: var(--bg-soft);
}
.cheatsheet__keys {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 130px;
flex-shrink: 0;
}
.cheatsheet__kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
font-family: var(--mono);
font-size: 11px;
color: var(--fg);
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 5px;
line-height: 1;
}
.cheatsheet__desc {
color: var(--fg-dim);
font-size: 12.5px;
}

.app {
height: 100%;
min-height: 0;
Expand Down
Loading