web: keyboard shortcuts
This commit is contained in:
@@ -40,6 +40,10 @@ Default dev URL: `http://localhost:5173`
|
|||||||
- Composer adapts to the active item:
|
- Composer adapts to the active item:
|
||||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
||||||
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
|
- Search sends `POST /v1/searches/:searchId/run/stream` (SSE).
|
||||||
|
- Keyboard shortcuts:
|
||||||
|
- `Cmd/Ctrl+J`: start a new chat.
|
||||||
|
- `Shift+Cmd/Ctrl+J`: start a new search.
|
||||||
|
- `Cmd/Ctrl+Up/Down`: move through the sidebar list.
|
||||||
|
|
||||||
Client API contract docs:
|
Client API contract docs:
|
||||||
- `../docs/api/rest.md`
|
- `../docs/api/rest.md`
|
||||||
|
|||||||
@@ -1008,6 +1008,10 @@ export default function App() {
|
|||||||
if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`;
|
if (selectedSearchSummary) return `${getSearchTitle(selectedSearchSummary)} — Sybil`;
|
||||||
return "Sybil";
|
return "Sybil";
|
||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
|
||||||
|
const primaryShortcutModifier = useMemo(() => {
|
||||||
|
if (typeof navigator === "undefined") return "Ctrl";
|
||||||
|
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? "Cmd" : "Ctrl";
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = pageTitle;
|
document.title = pageTitle;
|
||||||
@@ -1035,6 +1039,56 @@ export default function App() {
|
|||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
|
||||||
|
if (!filteredSidebarItems.length) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setContextMenu(null);
|
||||||
|
setDraftKind(null);
|
||||||
|
setIsMobileSidebarOpen(false);
|
||||||
|
setSelectedItem((current) => {
|
||||||
|
const currentIndex = current
|
||||||
|
? filteredSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
|
||||||
|
: -1;
|
||||||
|
const fallbackIndex = direction > 0 ? 0 : filteredSidebarItems.length - 1;
|
||||||
|
const nextIndex =
|
||||||
|
currentIndex < 0
|
||||||
|
? fallbackIndex
|
||||||
|
: Math.min(filteredSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
||||||
|
const nextItem = filteredSidebarItems[nextIndex];
|
||||||
|
return { kind: nextItem.kind, id: nextItem.id };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
||||||
|
if (!hasPrimaryModifier || event.altKey) return;
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "j") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.shiftKey) {
|
||||||
|
handleCreateSearch();
|
||||||
|
} else {
|
||||||
|
handleCreateChat();
|
||||||
|
}
|
||||||
|
focusComposer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectAdjacentSidebarItem(event.key === "ArrowUp" ? -1 : 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [filteredSidebarItems, isAuthenticated]);
|
||||||
|
|
||||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const menuWidth = 160;
|
const menuWidth = 160;
|
||||||
@@ -1632,10 +1686,16 @@ export default function App() {
|
|||||||
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New chat
|
New chat
|
||||||
|
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
|
||||||
|
{primaryShortcutModifier} J
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
New search
|
New search
|
||||||
|
<span className="ml-auto rounded-md border border-violet-100/10 bg-white/[0.035] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/44">
|
||||||
|
Shift {primaryShortcutModifier} J
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
|
||||||
|
|||||||
Reference in New Issue
Block a user