Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@t3tools/contracts": "workspace:*",
"@tanstack/react-hotkeys": "^0.1.0",
"@tanstack/react-pacer": "^0.19.4",
"@tanstack/react-query": "^5.90.0",
"@xterm/addon-fit": "^0.11.0",
Expand Down
186 changes: 117 additions & 69 deletions apps/web/src/components/ChatView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,7 @@ import {
useRef,
useState,
} from "react";
import { getHotkeyManager, useHotkey } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
Expand DownExpand Up@@ -66,6 +67,7 @@ import BranchToolbar from "./BranchToolbar";
import GitActionsControl from "./GitActionsControl";
import {
isOpenFavoriteEditorShortcut,
shortcutsForCommands,
isTerminalCloseShortcut,
isTerminalNewShortcut,
isTerminalSplitShortcut,
Expand DownExpand Up@@ -524,6 +526,16 @@ export default function ChatView() {
() => shortcutLabelForCommand(keybindings, "terminal.close"),
[keybindings],
);
const terminalHotkeys = useMemo(
() =>
shortcutsForCommands(keybindings, [
"terminal.toggle",
"terminal.split",
"terminal.close",
"terminal.new",
]),
[keybindings],
);

const envLocked = Boolean(
activeThread &&
Expand DownExpand Up@@ -716,22 +728,19 @@ export default function ChatView() {
};
}, [revokePreviewUrls]);

/**
* Close expanded image on Escape key
*/
useEffect(() => {
if (!expandedImage) {
return;
}

const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "Escape") return;
useHotkey(
"Escape",
() => {
setExpandedImage(null);
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expandedImage]);
},
{
enabled: expandedImage !== null,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: typeof window === "undefined" ? null : window,
},
);

const activeWorktreePath = activeThread?.worktreePath;

Expand DownExpand Up@@ -786,56 +795,74 @@ export default function ChatView() {
return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
};

const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};
const manager = getHotkeyManager();
const handles = terminalHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!activeThreadId || event.defaultPrevented) return;
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(activeThread?.terminalOpen),
};

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}

if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
toggleTerminalVisibility();
return;
}
if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}

if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
splitTerminal();
return;
}
if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}

if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) return;
closeTerminal(activeThread.activeTerminalId);
return;
}
if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
}
createNewTerminal();
},
{
enabled: Boolean(activeThreadId),
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
Comment on lines +799 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared handler re-checks every shortcut for each hotkey

Each RawHotkey from terminalHotkeys is registered with the same handler that sequentially checks isTerminalToggleShortcut, isTerminalSplitShortcut, isTerminalCloseShortcut, and isTerminalNewShortcut. Since shortcutsForCommands already extracts the distinct key combos, when e.g. the Mod+J hotkey fires, it still runs through the split/close/new checks unnecessarily before (or after) finding the toggle match.

This works correctly because only the matching is*Shortcut call will return true, but it's doing redundant work on every keypress. Consider registering each command's hotkeys separately with a dedicated handler to avoid the unnecessary checks and make the intent clearer:

constterminalCommands=[{commands: ["terminal.toggle"]asconst,action: ()=>toggleTerminalVisibility()},{commands: ["terminal.split"]asconst,action: ()=>{/* ... */}},// ...]asconst;

This isn't a functional issue — just something to consider for clarity as more shortcuts are added.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

);

if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
event.preventDefault();
event.stopPropagation();
if (!activeThread?.terminalOpen) {
dispatch({
type: "SET_THREAD_TERMINAL_OPEN",
threadId: activeThreadId,
open: true,
});
return () => {
for (const handle of handles) {
handle.unregister();
}
createNewTerminal();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
activeThread?.terminalOpen,
activeThread?.activeTerminalId,
Expand All@@ -845,6 +872,7 @@ export default function ChatView() {
dispatch,
splitTerminal,
keybindings,
terminalHotkeys,
toggleTerminalVisibility,
]);

Expand DownExpand Up@@ -2095,19 +2123,39 @@ const OpenInPicker = memo(function OpenInPicker({
() => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
[keybindings],
);
const openFavoriteHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["editor.openFavorite"]),
[keybindings],
);

useEffect(() => {
const handler = (e: globalThis.KeyboardEvent) => {
if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
if (!api || !activeProject) return;

e.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
const manager = getHotkeyManager();
const handles = openFavoriteHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
if (!isOpenFavoriteEditorShortcut(event, keybindings)) return;
if (!api || !activeProject) return;

event.preventDefault();
const cwd = activeThread?.worktreePath ?? activeProject.cwd;
void api.shell.openInEditor(cwd, lastEditor);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
for (const handle of handles) {
handle.unregister();
}
};
Comment on lines 2131 to 2157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant isOpenFavoriteEditorShortcut re-check inside hotkey handler

The handler is only invoked when TanStack matches the registered openFavoriteHotkeys key combo. Then line 2137 re-checks the same event with isOpenFavoriteEditorShortcut(event, keybindings). Since shortcutsForCommands already filtered for "editor.openFavorite", this inner check will always pass when the correct key combo fires — unless the keybinding has a when clause that the TanStack registration doesn't evaluate. Given that editor.openFavorite bindings in this codebase don't use when clauses, this re-check is a no-op guard.

If when-clause awareness is intended to be preserved here, it would be worth adding a comment explaining that; otherwise the check can be removed to simplify the handler.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [api, activeProject, activeThread, keybindings, lastEditor]);
}, [activeProject, activeThread, api, keybindings, lastEditor, openFavoriteHotkeys]);

return (
<Group aria-label="Subscription actions">
Expand Down
65 changes: 42 additions & 23 deletions apps/web/src/components/Sidebar.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
import { getHotkeyManager } from "@tanstack/react-hotkeys";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { DEFAULT_MODEL } from "../model-logic";
import { derivePendingApprovals } from "../session-logic";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand DownExpand Up@@ -385,32 +386,50 @@ export default function Sidebar() {
[api, dispatch, state.projects, state.threads],
);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}
const chatHotkeys = useMemo(
() => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]),
[keybindings],
);

if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
};
useEffect(() => {
const manager = getHotkeyManager();
const handles = chatHotkeys.map((hotkey) =>
manager.register(
hotkey,
(event) => {
const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
if (isChatNewLocalShortcut(event, keybindings)) {
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId);
return;
}

window.addEventListener("keydown", onWindowKeyDown);
if (!isChatNewShortcut(event, keybindings)) return;
const projectId = activeThread?.projectId ?? state.projects[0]?.id;
if (!projectId) return;
event.preventDefault();
handleNewThread(projectId, {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
});
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: window,
},
),
);
return () => {
window.removeEventListener("keydown", onWindowKeyDown);
for (const handle of handles) {
handle.unregister();
}
};
}, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
}, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
Comment on lines +394 to +432

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same shared-handler pattern; consider per-command registration

Same note as the terminal hotkeys in ChatView.tsx: every hotkey in chatHotkeys (covering both chat.new and chat.newLocal) receives the same handler that checks isChatNewLocalShortcut then isChatNewShortcut. This works but is somewhat fragile if someone later changes shortcut config to overlap the two commands, since the check order implicitly gives chat.newLocal priority.

Registering each command's hotkeys with its own focused handler would make the precedence explicit and remove the dead-code paths per invocation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


return (
<aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/contextMenuFallback.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { getHotkeyManager } from "@tanstack/react-hotkeys";

/**
* Imperative DOM-based context menu for non-Electron environments.
* Shows a positioned dropdown and returns a promise that resolves
Expand All@@ -10,6 +12,7 @@ export function showContextMenuFallback<T extends string>(
return new Promise<T | null>((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
let cleanedUp = false;

const menu = document.createElement("div");
menu.className =
Expand All@@ -19,23 +22,31 @@ export function showContextMenuFallback<T extends string>(
const y = position?.y ?? 0;
menu.style.top = `${y}px`;
menu.style.left = `${x}px`;
const escapeHandle = getHotkeyManager().register(
"Escape",
(event) => {
event.preventDefault();
cleanup(null);
},
{
conflictBehavior: "allow",
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: document,
},
);

function cleanup(result: T | null) {
document.removeEventListener("keydown", onKeyDown);
if (cleanedUp) return;
cleanedUp = true;
escapeHandle.unregister();
overlay.remove();
menu.remove();
resolve(result);
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cleanup(null);
}
}

overlay.addEventListener("mousedown", () => cleanup(null));
document.addEventListener("keydown", onKeyDown);

for (const item of items) {
const btn = document.createElement("button");
Expand Down
Loading