authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-12 11:19:55-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-12 11:50:35-07:00
logfcbf3f3c225ab41c44af3759ae27d8520ced61a4
treed85a771d4b2672fd10632a0c20e3ca2403a4af27
parentab0002f99d6fb5555c57bc91f696e6a9f8f4dfd7

Fix new-thread shortcuts when terminal is focused

- move chat-wide key handling into `_chat` route-level shortcut handler - extract reusable `useHandleNewThread` hook and `isTerminalFocused` helper - update browser WS fixture to support `terminalOpen` RPC shape

6 files changed, 221 insertions(+), 133 deletions(-)

apps/web/src/components/ChatView.browser.tsx+16-2
...@@ -353,7 +353,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {...@@ -353,7 +353,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
353 };353 };
354}354}
355355
356function resolveWsRpc(tag: string): unknown {356function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
357 const tag = body._tag;
357 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {358 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
358 return fixture.snapshot;359 return fixture.snapshot;
359 }360 }
...@@ -395,6 +396,19 @@ function resolveWsRpc(tag: string): unknown {...@@ -395,6 +396,19 @@ function resolveWsRpc(tag: string): unknown {
395 truncated: false,396 truncated: false,
396 };397 };
397 }398 }
399 if (tag === WS_METHODS.terminalOpen) {
400 return {
401 threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID,
402 terminalId: typeof body.terminalId === "string" ? body.terminalId : "default",
403 cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project",
404 status: "running",
405 pid: 123,
406 history: "",
407 exitCode: null,
408 exitSignal: null,
409 updatedAt: NOW_ISO,
410 };
411 }
398 return {};412 return {};
399}413}
400414
...@@ -423,7 +437,7 @@ const worker = setupWorker(...@@ -423,7 +437,7 @@ const worker = setupWorker(
423 client.send(437 client.send(
424 JSON.stringify({438 JSON.stringify({
425 id: request.id,439 id: request.id,
426 result: resolveWsRpc(method),440 result: resolveWsRpc(request.body),
427 }),441 }),
428 );442 );
429 });443 });
apps/web/src/components/ChatView.tsx+1-7
...@@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar";...@@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar";
117import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";117import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
118import { readNativeApi } from "~/nativeApi";118import { readNativeApi } from "~/nativeApi";
119import { resolveAppModelSelection, useAppSettings } from "../appSettings";119import { resolveAppModelSelection, useAppSettings } from "../appSettings";
120import { isTerminalFocused } from "../lib/terminalFocus";
120import {121import {
121 type ComposerImageAttachment,122 type ComposerImageAttachment,
122 type DraftThreadEnvMode,123 type DraftThreadEnvMode,
...@@ -1937,13 +1938,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1937,13 +1938,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1937 }, [activeThreadId, focusComposer, terminalState.terminalOpen]);1938 }, [activeThreadId, focusComposer, terminalState.terminalOpen]);
19381939
1939 useEffect(() => {1940 useEffect(() => {
1940 const isTerminalFocused = (): boolean => {
1941 const activeElement = document.activeElement;
1942 if (!(activeElement instanceof HTMLElement)) return false;
1943 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
1944 return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
1945 };
1946
1947 const handler = (event: globalThis.KeyboardEvent) => {1941 const handler = (event: globalThis.KeyboardEvent) => {
1948 if (!activeThreadId || event.defaultPrevented) return;1942 if (!activeThreadId || event.defaultPrevented) return;
1949 const shortcutContext = {1943 const shortcutContext = {
apps/web/src/components/Sidebar.tsx+6-124
...@@ -27,7 +27,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-...@@ -27,7 +27,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
27import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";27import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
28import { CSS } from "@dnd-kit/utilities";28import { CSS } from "@dnd-kit/utilities";
29import {29import {
30 DEFAULT_RUNTIME_MODE,
31 DEFAULT_MODEL_BY_PROVIDER,30 DEFAULT_MODEL_BY_PROVIDER,
32 type DesktopUpdateState,31 type DesktopUpdateState,
33 ProjectId,32 ProjectId,
...@@ -40,14 +39,15 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router";...@@ -40,14 +39,15 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
40import { useAppSettings } from "../appSettings";39import { useAppSettings } from "../appSettings";
41import { isElectron } from "../env";40import { isElectron } from "../env";
42import { APP_STAGE_LABEL, APP_VERSION } from "../branding";41import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
43import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils";42import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
44import { useStore } from "../store";43import { useStore } from "../store";
45import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";44import { shortcutLabelForCommand } from "../keybindings";
46import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";45import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
47import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";46import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
48import { serverConfigQueryOptions } from "../lib/serverReactQuery";47import { serverConfigQueryOptions } from "../lib/serverReactQuery";
49import { readNativeApi } from "../nativeApi";48import { readNativeApi } from "../nativeApi";
50import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";49import { useComposerDraftStore } from "../composerDraftStore";
50import { useHandleNewThread } from "../hooks/useHandleNewThread";
51import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";51import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
52import { toastManager } from "./ui/toast";52import { toastManager } from "./ui/toast";
53import {53import {
...@@ -262,11 +262,8 @@ export default function Sidebar() {...@@ -262,11 +262,8 @@ export default function Sidebar() {
262 const getDraftThreadByProjectId = useComposerDraftStore(262 const getDraftThreadByProjectId = useComposerDraftStore(
263 (store) => store.getDraftThreadByProjectId,263 (store) => store.getDraftThreadByProjectId,
264 );264 );
265 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
266 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);265 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
267 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);266 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
268 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
269 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
270 const clearProjectDraftThreadId = useComposerDraftStore(267 const clearProjectDraftThreadId = useComposerDraftStore(
271 (store) => store.clearProjectDraftThreadId,268 (store) => store.clearProjectDraftThreadId,
272 );269 );
...@@ -276,6 +273,7 @@ export default function Sidebar() {...@@ -276,6 +273,7 @@ export default function Sidebar() {
276 const navigate = useNavigate();273 const navigate = useNavigate();
277 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });274 const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" });
278 const { settings: appSettings } = useAppSettings();275 const { settings: appSettings } = useAppSettings();
276 const { handleNewThread } = useHandleNewThread();
279 const routeThreadId = useParams({277 const routeThreadId = useParams({
280 strict: false,278 strict: false,
281 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),279 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
...@@ -384,80 +382,6 @@ export default function Sidebar() {...@@ -384,80 +382,6 @@ export default function Sidebar() {
384 });382 });
385 }, []);383 }, []);
386384
387 const handleNewThread = useCallback(
388 (
389 projectId: ProjectId,
390 options?: {
391 branch?: string | null;
392 worktreePath?: string | null;
393 envMode?: DraftThreadEnvMode;
394 },
395 ): Promise<void> => {
396 const hasBranchOption = options?.branch !== undefined;
397 const hasWorktreePathOption = options?.worktreePath !== undefined;
398 const hasEnvModeOption = options?.envMode !== undefined;
399 const storedDraftThread = getDraftThreadByProjectId(projectId);
400 if (storedDraftThread) {
401 return (async () => {
402 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
403 setDraftThreadContext(storedDraftThread.threadId, {
404 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
405 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
406 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
407 });
408 }
409 setProjectDraftThreadId(projectId, storedDraftThread.threadId);
410 if (routeThreadId === storedDraftThread.threadId) {
411 return;
412 }
413 await navigate({
414 to: "/$threadId",
415 params: { threadId: storedDraftThread.threadId },
416 });
417 })();
418 }
419 clearProjectDraftThreadId(projectId);
420
421 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
422 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
423 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
424 setDraftThreadContext(routeThreadId, {
425 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
426 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
427 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
428 });
429 }
430 setProjectDraftThreadId(projectId, routeThreadId);
431 return Promise.resolve();
432 }
433 const threadId = newThreadId();
434 const createdAt = new Date().toISOString();
435 return (async () => {
436 setProjectDraftThreadId(projectId, threadId, {
437 createdAt,
438 branch: options?.branch ?? null,
439 worktreePath: options?.worktreePath ?? null,
440 envMode: options?.envMode ?? "local",
441 runtimeMode: DEFAULT_RUNTIME_MODE,
442 });
443
444 await navigate({
445 to: "/$threadId",
446 params: { threadId },
447 });
448 })();
449 },
450 [
451 clearProjectDraftThreadId,
452 getDraftThreadByProjectId,
453 navigate,
454 getDraftThread,
455 routeThreadId,
456 setDraftThreadContext,
457 setProjectDraftThreadId,
458 ],
459 );
460
461 const focusMostRecentThreadForProject = useCallback(385 const focusMostRecentThreadForProject = useCallback(
462 (projectId: ProjectId) => {386 (projectId: ProjectId) => {
463 const latestThread = threads387 const latestThread = threads
...@@ -1021,37 +945,6 @@ export default function Sidebar() {...@@ -1021,37 +945,6 @@ export default function Sidebar() {
1021 );945 );
1022946
1023 useEffect(() => {947 useEffect(() => {
1024 const onWindowKeyDown = (event: KeyboardEvent) => {
1025 if (event.key === "Escape" && selectedThreadIds.size > 0) {
1026 event.preventDefault();
1027 clearSelection();
1028 return;
1029 }
1030
1031 const activeThread = routeThreadId
1032 ? threads.find((thread) => thread.id === routeThreadId)
1033 : undefined;
1034 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
1035 if (isChatNewLocalShortcut(event, keybindings)) {
1036 const projectId =
1037 activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;
1038 if (!projectId) return;
1039 event.preventDefault();
1040 void handleNewThread(projectId);
1041 return;
1042 }
1043
1044 if (!isChatNewShortcut(event, keybindings)) return;
1045 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;
1046 if (!projectId) return;
1047 event.preventDefault();
1048 void handleNewThread(projectId, {
1049 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
1050 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
1051 envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),
1052 });
1053 };
1054
1055 const onMouseDown = (event: globalThis.MouseEvent) => {948 const onMouseDown = (event: globalThis.MouseEvent) => {
1056 if (selectedThreadIds.size === 0) return;949 if (selectedThreadIds.size === 0) return;
1057 const target = event.target instanceof HTMLElement ? event.target : null;950 const target = event.target instanceof HTMLElement ? event.target : null;
...@@ -1059,22 +952,11 @@ export default function Sidebar() {...@@ -1059,22 +952,11 @@ export default function Sidebar() {
1059 clearSelection();952 clearSelection();
1060 };953 };
1061954
1062 window.addEventListener("keydown", onWindowKeyDown);
1063 window.addEventListener("mousedown", onMouseDown);955 window.addEventListener("mousedown", onMouseDown);
1064 return () => {956 return () => {
1065 window.removeEventListener("keydown", onWindowKeyDown);
1066 window.removeEventListener("mousedown", onMouseDown);957 window.removeEventListener("mousedown", onMouseDown);
1067 };958 };
1068 }, [959 }, [clearSelection, selectedThreadIds.size]);
1069 clearSelection,
1070 getDraftThread,
1071 handleNewThread,
1072 keybindings,
1073 projects,
1074 routeThreadId,
1075 selectedThreadIds.size,
1076 threads,
1077 ]);
1078960
1079 useEffect(() => {961 useEffect(() => {
1080 if (!isElectron) return;962 if (!isElectron) return;
apps/web/src/hooks/useHandleNewThread.ts created+113
...@@ -0,0 +1,113 @@
1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
2import { useNavigate, useParams } from "@tanstack/react-router";
3import { useCallback } from "react";
4import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
5import { newThreadId } from "../lib/utils";
6import { useStore } from "../store";
7
8export function useHandleNewThread() {
9 const projects = useStore((store) => store.projects);
10 const threads = useStore((store) => store.threads);
11 const getDraftThreadByProjectId = useComposerDraftStore(
12 (store) => store.getDraftThreadByProjectId,
13 );
14 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
15 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
16 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
17 const clearProjectDraftThreadId = useComposerDraftStore(
18 (store) => store.clearProjectDraftThreadId,
19 );
20 const navigate = useNavigate();
21 const routeThreadId = useParams({
22 strict: false,
23 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
24 });
25
26 const activeThread = routeThreadId
27 ? threads.find((thread) => thread.id === routeThreadId)
28 : undefined;
29 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
30
31 const handleNewThread = useCallback(
32 (
33 projectId: ProjectId,
34 options?: {
35 branch?: string | null;
36 worktreePath?: string | null;
37 envMode?: DraftThreadEnvMode;
38 },
39 ): Promise<void> => {
40 const hasBranchOption = options?.branch !== undefined;
41 const hasWorktreePathOption = options?.worktreePath !== undefined;
42 const hasEnvModeOption = options?.envMode !== undefined;
43 const storedDraftThread = getDraftThreadByProjectId(projectId);
44 if (storedDraftThread) {
45 return (async () => {
46 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
47 setDraftThreadContext(storedDraftThread.threadId, {
48 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
49 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
50 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
51 });
52 }
53 setProjectDraftThreadId(projectId, storedDraftThread.threadId);
54 if (routeThreadId === storedDraftThread.threadId) {
55 return;
56 }
57 await navigate({
58 to: "/$threadId",
59 params: { threadId: storedDraftThread.threadId },
60 });
61 })();
62 }
63
64 clearProjectDraftThreadId(projectId);
65
66 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
67 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
68 setDraftThreadContext(routeThreadId, {
69 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
70 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
71 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
72 });
73 }
74 setProjectDraftThreadId(projectId, routeThreadId);
75 return Promise.resolve();
76 }
77
78 const threadId = newThreadId();
79 const createdAt = new Date().toISOString();
80 return (async () => {
81 setProjectDraftThreadId(projectId, threadId, {
82 createdAt,
83 branch: options?.branch ?? null,
84 worktreePath: options?.worktreePath ?? null,
85 envMode: options?.envMode ?? "local",
86 runtimeMode: DEFAULT_RUNTIME_MODE,
87 });
88
89 await navigate({
90 to: "/$threadId",
91 params: { threadId },
92 });
93 })();
94 },
95 [
96 activeDraftThread,
97 clearProjectDraftThreadId,
98 getDraftThreadByProjectId,
99 navigate,
100 routeThreadId,
101 setDraftThreadContext,
102 setProjectDraftThreadId,
103 ],
104 );
105
106 return {
107 activeDraftThread,
108 activeThread,
109 handleNewThread,
110 projects,
111 routeThreadId,
112 };
113}
apps/web/src/lib/terminalFocus.ts created+6
...@@ -0,0 +1,6 @@
1export function isTerminalFocused(): boolean {
2 const activeElement = document.activeElement;
3 if (!(activeElement instanceof HTMLElement)) return false;
4 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
5 return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
6}
apps/web/src/routes/_chat.tsx+79
...@@ -1,10 +1,88 @@...@@ -1,10 +1,88 @@
1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { useQuery } from "@tanstack/react-query";
1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";3import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";4import { useEffect } from "react";
35
4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
5import ThreadSidebar from "../components/Sidebar";7import ThreadSidebar from "../components/Sidebar";
8import { useHandleNewThread } from "../hooks/useHandleNewThread";
9import { isTerminalFocused } from "../lib/terminalFocus";
10import { serverConfigQueryOptions } from "../lib/serverReactQuery";
11import { resolveShortcutCommand } from "../keybindings";
12import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
13import { useThreadSelectionStore } from "../threadSelectionStore";
6import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";14import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";
715
16const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
17
18function ChatRouteGlobalShortcuts() {
19 const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
20 const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size);
21 const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } =
22 useHandleNewThread();
23 const serverConfigQuery = useQuery(serverConfigQueryOptions());
24 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
25 const terminalOpen = useTerminalStateStore((state) =>
26 routeThreadId
27 ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen
28 : false,
29 );
30
31 useEffect(() => {
32 const onWindowKeyDown = (event: KeyboardEvent) => {
33 if (event.defaultPrevented) return;
34
35 if (event.key === "Escape" && selectedThreadIdsSize > 0) {
36 event.preventDefault();
37 clearSelection();
38 return;
39 }
40
41 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id;
42 if (!projectId) return;
43
44 const command = resolveShortcutCommand(event, keybindings, {
45 context: {
46 terminalFocus: isTerminalFocused(),
47 terminalOpen,
48 },
49 });
50
51 if (command === "chat.newLocal") {
52 event.preventDefault();
53 event.stopPropagation();
54 void handleNewThread(projectId);
55 return;
56 }
57
58 if (command !== "chat.new") return;
59 event.preventDefault();
60 event.stopPropagation();
61 void handleNewThread(projectId, {
62 branch: activeThread?.branch ?? activeDraftThread?.branch ?? null,
63 worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null,
64 envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),
65 });
66 };
67
68 window.addEventListener("keydown", onWindowKeyDown);
69 return () => {
70 window.removeEventListener("keydown", onWindowKeyDown);
71 };
72 }, [
73 activeDraftThread,
74 activeThread,
75 clearSelection,
76 handleNewThread,
77 keybindings,
78 projects,
79 selectedThreadIdsSize,
80 terminalOpen,
81 ]);
82
83 return null;
84}
85
8function ChatRouteLayout() {86function ChatRouteLayout() {
9 const navigate = useNavigate();87 const navigate = useNavigate();
1088
...@@ -26,6 +104,7 @@ function ChatRouteLayout() {...@@ -26,6 +104,7 @@ function ChatRouteLayout() {
26104
27 return (105 return (
28 <SidebarProvider defaultOpen>106 <SidebarProvider defaultOpen>
107 <ChatRouteGlobalShortcuts />
29 <Sidebar108 <Sidebar
30 side="left"109 side="left"
31 collapsible="offcanvas"110 collapsible="offcanvas"