Skip to content

Commit 5c8504d

Browse files
Add composable workspace pane controls
- add workspace pane focus, split, resize, and zoom commands - wire chat composer focus repair across workspace activation - enable dragging threads into workspace surfaces
1 parent d3d096c commit 5c8504d

19 files changed

Lines changed: 1996 additions & 229 deletions

apps/server/src/keybindings.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,21 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
188188
DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const),
189189
);
190190

191+
assert.equal(defaultsByCommand.get("workspace.pane.splitRight"), "mod+d");
192+
assert.equal(defaultsByCommand.get("workspace.pane.splitDown"), "mod+shift+d");
193+
assert.equal(defaultsByCommand.get("workspace.pane.close"), "mod+w");
194+
assert.equal(defaultsByCommand.get("workspace.focus.previous"), "mod+[");
195+
assert.equal(defaultsByCommand.get("workspace.focus.next"), "mod+]");
196+
assert.equal(defaultsByCommand.get("workspace.focus.left"), "mod+alt+arrowleft");
197+
assert.equal(defaultsByCommand.get("workspace.focus.right"), "mod+alt+arrowright");
198+
assert.equal(defaultsByCommand.get("workspace.focus.up"), "mod+alt+arrowup");
199+
assert.equal(defaultsByCommand.get("workspace.focus.down"), "mod+alt+arrowdown");
200+
assert.equal(defaultsByCommand.get("workspace.pane.toggleZoom"), "mod+shift+enter");
201+
assert.equal(defaultsByCommand.get("workspace.pane.resizeLeft"), "mod+ctrl+arrowleft");
202+
assert.equal(defaultsByCommand.get("workspace.pane.resizeRight"), "mod+ctrl+arrowright");
203+
assert.equal(defaultsByCommand.get("workspace.pane.resizeUp"), "mod+ctrl+arrowup");
204+
assert.equal(defaultsByCommand.get("workspace.pane.resizeDown"), "mod+ctrl+arrowdown");
205+
assert.equal(defaultsByCommand.get("workspace.pane.equalize"), "mod+ctrl+=");
191206
assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
192207
assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
193208
assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");

apps/server/src/keybindings.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,22 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
6060
{ key: "mod+d", command: "terminal.split", when: "terminalFocus" },
6161
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
6262
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
63-
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
63+
{ key: "mod+d", command: "workspace.pane.splitRight", when: "!terminalFocus" },
64+
{ key: "mod+shift+d", command: "workspace.pane.splitDown", when: "!terminalFocus" },
65+
{ key: "mod+w", command: "workspace.pane.close", when: "!terminalFocus" },
66+
{ key: "mod+[", command: "workspace.focus.previous" },
67+
{ key: "mod+]", command: "workspace.focus.next" },
68+
{ key: "mod+alt+arrowup", command: "workspace.focus.up" },
69+
{ key: "mod+alt+arrowdown", command: "workspace.focus.down" },
70+
{ key: "mod+alt+arrowleft", command: "workspace.focus.left" },
71+
{ key: "mod+alt+arrowright", command: "workspace.focus.right" },
72+
{ key: "mod+shift+enter", command: "workspace.pane.toggleZoom" },
73+
{ key: "mod+ctrl+arrowup", command: "workspace.pane.resizeUp" },
74+
{ key: "mod+ctrl+arrowdown", command: "workspace.pane.resizeDown" },
75+
{ key: "mod+ctrl+arrowleft", command: "workspace.pane.resizeLeft" },
76+
{ key: "mod+ctrl+arrowright", command: "workspace.pane.resizeRight" },
77+
{ key: "mod+ctrl+=", command: "workspace.pane.equalize" },
78+
{ key: "mod+alt+d", command: "diff.toggle", when: "!terminalFocus" },
6479
{ key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
6580
{ key: "mod+n", command: "chat.new", when: "!terminalFocus" },
6681
{ key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },

apps/web/src/components/ChatView.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
2424
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
2525
import { truncate } from "@t3tools/shared/String";
2626
import { Debouncer } from "@tanstack/react-pacer";
27-
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
27+
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2828
import { useNavigate, useSearch } from "@tanstack/react-router";
2929
import { useShallow } from "zustand/react/shallow";
3030
import { useGitStatus } from "~/lib/gitStatusState";
@@ -305,6 +305,7 @@ const SCRIPT_TERMINAL_ROWS = 30;
305305

306306
type ChatViewProps =
307307
| {
308+
activationFocusRequestId?: number;
308309
bindSharedComposerHandle?: boolean;
309310
environmentId: EnvironmentId;
310311
threadId: ThreadId;
@@ -314,6 +315,7 @@ type ChatViewProps =
314315
draftId?: never;
315316
}
316317
| {
318+
activationFocusRequestId?: number;
317319
bindSharedComposerHandle?: boolean;
318320
environmentId: EnvironmentId;
319321
threadId: ThreadId;
@@ -392,6 +394,7 @@ function useLocalDispatchState(input: {
392394

393395
export default function ChatView(props: ChatViewProps) {
394396
const {
397+
activationFocusRequestId,
395398
bindSharedComposerHandle = true,
396399
environmentId,
397400
threadId,
@@ -470,9 +473,7 @@ export default function ChatView(props: ChatViewProps) {
470473
const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
471474
const localComposerRef = useRef<ChatComposerHandle | null>(null);
472475
const sharedComposerRef = useComposerHandleContext();
473-
const composerRef = bindSharedComposerHandle
474-
? (sharedComposerRef ?? localComposerRef)
475-
: localComposerRef;
476+
const composerRef = localComposerRef;
476477
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
477478
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
478479
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
@@ -520,6 +521,26 @@ export default function ChatView(props: ChatViewProps) {
520521
const sendInFlightRef = useRef(false);
521522
const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
522523

524+
useLayoutEffect(() => {
525+
if (!sharedComposerRef) {
526+
return;
527+
}
528+
529+
const localComposerHandle = localComposerRef.current;
530+
if (bindSharedComposerHandle) {
531+
sharedComposerRef.current = localComposerHandle;
532+
return () => {
533+
if (sharedComposerRef.current === localComposerHandle) {
534+
sharedComposerRef.current = null;
535+
}
536+
};
537+
}
538+
539+
if (sharedComposerRef.current === localComposerHandle) {
540+
sharedComposerRef.current = null;
541+
}
542+
});
543+
523544
const terminalState = useTerminalStateStore((state) =>
524545
selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef),
525546
);
@@ -1747,6 +1768,13 @@ export default function ChatView(props: ChatViewProps) {
17471768
};
17481769
}, [activeThread?.id, focusComposer, terminalSurfaceOpen]);
17491770

1771+
useLayoutEffect(() => {
1772+
if (activationFocusRequestId === undefined) {
1773+
return;
1774+
}
1775+
focusComposer();
1776+
}, [activationFocusRequestId, focusComposer]);
1777+
17501778
useEffect(() => {
17511779
if (!activeThread?.id) return;
17521780
if (activeThread.messages.length === 0) {
@@ -2980,6 +3008,7 @@ export default function ChatView(props: ChatViewProps) {
29803008
<div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
29813009
<ChatComposer
29823010
ref={composerRef}
3011+
{...(activationFocusRequestId === undefined ? {} : { activationFocusRequestId })}
29833012
composerDraftTarget={composerDraftTarget}
29843013
environmentId={environmentId}
29853014
routeKind={routeKind}

apps/web/src/components/CommandPalette.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,9 +453,42 @@ function OpenCommandPaletteDialog() {
453453
await executeWorkspaceCommand("workspace.pane.close");
454454
},
455455
});
456+
actionItems.push({
457+
kind: "action",
458+
value: "action:workspace-pane-toggle-zoom",
459+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.toggleZoom"].searchTerms,
460+
title: WORKSPACE_COMMAND_METADATA["workspace.pane.toggleZoom"].title,
461+
icon: <Columns2Icon className={ITEM_ICON_CLASS} />,
462+
shortcutCommand: "workspace.pane.toggleZoom",
463+
run: async () => {
464+
await executeWorkspaceCommand("workspace.pane.toggleZoom");
465+
},
466+
});
456467
}
457468

458469
if (canUseSpatialWorkspaceCommands) {
470+
actionItems.push({
471+
kind: "action",
472+
value: "action:workspace-focus-previous",
473+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.previous"].searchTerms,
474+
title: WORKSPACE_COMMAND_METADATA["workspace.focus.previous"].title,
475+
icon: <ArrowLeftIcon className={ITEM_ICON_CLASS} />,
476+
shortcutCommand: "workspace.focus.previous",
477+
run: async () => {
478+
await executeWorkspaceCommand("workspace.focus.previous");
479+
},
480+
});
481+
actionItems.push({
482+
kind: "action",
483+
value: "action:workspace-focus-next",
484+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.next"].searchTerms,
485+
title: WORKSPACE_COMMAND_METADATA["workspace.focus.next"].title,
486+
icon: <ArrowRightIcon className={ITEM_ICON_CLASS} />,
487+
shortcutCommand: "workspace.focus.next",
488+
run: async () => {
489+
await executeWorkspaceCommand("workspace.focus.next");
490+
},
491+
});
459492
actionItems.push({
460493
kind: "action",
461494
value: "action:workspace-focus-left",
@@ -500,6 +533,61 @@ function OpenCommandPaletteDialog() {
500533
await executeWorkspaceCommand("workspace.focus.down");
501534
},
502535
});
536+
actionItems.push({
537+
kind: "action",
538+
value: "action:workspace-pane-resize-left",
539+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeLeft"].searchTerms,
540+
title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeLeft"].title,
541+
icon: <ArrowLeftIcon className={ITEM_ICON_CLASS} />,
542+
shortcutCommand: "workspace.pane.resizeLeft",
543+
run: async () => {
544+
await executeWorkspaceCommand("workspace.pane.resizeLeft");
545+
},
546+
});
547+
actionItems.push({
548+
kind: "action",
549+
value: "action:workspace-pane-resize-right",
550+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeRight"].searchTerms,
551+
title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeRight"].title,
552+
icon: <ArrowRightIcon className={ITEM_ICON_CLASS} />,
553+
shortcutCommand: "workspace.pane.resizeRight",
554+
run: async () => {
555+
await executeWorkspaceCommand("workspace.pane.resizeRight");
556+
},
557+
});
558+
actionItems.push({
559+
kind: "action",
560+
value: "action:workspace-pane-resize-up",
561+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeUp"].searchTerms,
562+
title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeUp"].title,
563+
icon: <ArrowUpIcon className={ITEM_ICON_CLASS} />,
564+
shortcutCommand: "workspace.pane.resizeUp",
565+
run: async () => {
566+
await executeWorkspaceCommand("workspace.pane.resizeUp");
567+
},
568+
});
569+
actionItems.push({
570+
kind: "action",
571+
value: "action:workspace-pane-resize-down",
572+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeDown"].searchTerms,
573+
title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeDown"].title,
574+
icon: <ArrowDownIcon className={ITEM_ICON_CLASS} />,
575+
shortcutCommand: "workspace.pane.resizeDown",
576+
run: async () => {
577+
await executeWorkspaceCommand("workspace.pane.resizeDown");
578+
},
579+
});
580+
actionItems.push({
581+
kind: "action",
582+
value: "action:workspace-pane-equalize",
583+
searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.equalize"].searchTerms,
584+
title: WORKSPACE_COMMAND_METADATA["workspace.pane.equalize"].title,
585+
icon: <Rows2Icon className={ITEM_ICON_CLASS} />,
586+
shortcutCommand: "workspace.pane.equalize",
587+
run: async () => {
588+
await executeWorkspaceCommand("workspace.pane.equalize");
589+
},
590+
});
503591
actionItems.push({
504592
kind: "action",
505593
value: "action:workspace-pane-move-left",

apps/web/src/components/ComposerPromptEditor.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,7 @@ export interface ComposerPromptEditorHandle {
890890
focus: () => void;
891891
focusAt: (cursor: number) => void;
892892
focusAtEnd: () => void;
893+
isFocused: () => boolean;
893894
readSnapshot: () => {
894895
value: string;
895896
cursor: number;
@@ -1383,7 +1384,6 @@ function ComposerSurroundSelectionPlugin(props: {
13831384
const onCompositionEnd = () => {
13841385
tryApplyDeadKeyBacktickSurround({ finalAttempt: true });
13851386
};
1386-
13871387
let activeRootElement: HTMLElement | null = null;
13881388
const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
13891389
prevRootElement?.removeEventListener("keydown", onKeyDown);
@@ -1493,7 +1493,7 @@ function ComposerPromptEditorInner({
14931493
if (shouldRewriteEditorState) {
14941494
$setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current);
14951495
}
1496-
if (shouldRewriteEditorState || isFocused) {
1496+
if (isFocused) {
14971497
$setSelectionAtComposerOffset(normalizedCursor);
14981498
}
14991499
});
@@ -1508,22 +1508,19 @@ function ComposerPromptEditorInner({
15081508
if (!rootElement) return;
15091509
const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor);
15101510
rootElement.focus();
1511+
isApplyingControlledUpdateRef.current = true;
15111512
editor.update(() => {
15121513
$setSelectionAtComposerOffset(boundedCursor);
15131514
});
1515+
queueMicrotask(() => {
1516+
isApplyingControlledUpdateRef.current = false;
1517+
});
15141518
snapshotRef.current = {
15151519
value: snapshotRef.current.value,
15161520
cursor: boundedCursor,
15171521
expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
15181522
terminalContextIds: snapshotRef.current.terminalContextIds,
15191523
};
1520-
onChangeRef.current(
1521-
snapshotRef.current.value,
1522-
boundedCursor,
1523-
snapshotRef.current.expandedCursor,
1524-
false,
1525-
snapshotRef.current.terminalContextIds,
1526-
);
15271524
},
15281525
[editor],
15291526
);
@@ -1577,9 +1574,13 @@ function ComposerPromptEditorInner({
15771574
),
15781575
);
15791576
},
1577+
isFocused: () => {
1578+
const rootElement = editor.getRootElement();
1579+
return Boolean(rootElement && document.activeElement === rootElement);
1580+
},
15801581
readSnapshot,
15811582
}),
1582-
[focusAt, readSnapshot],
1583+
[editor, focusAt, readSnapshot],
15831584
);
15841585

15851586
const handleEditorChange = useCallback((editorState: EditorState) => {

apps/web/src/components/Sidebar.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ import {
151151
useSavedEnvironmentRuntimeStore,
152152
} from "../environments/runtime";
153153
import { serverThreadSurfaceInput } from "../workspace/types";
154+
import { useWorkspaceDragStore } from "../workspace/dragStore";
154155
import { useWorkspaceStore, useWorkspaceThreadTerminalOpen } from "../workspace/store";
155156
import type { Project, SidebarThreadSummary } from "../types";
156157
const THREAD_PREVIEW_LIMIT = 6;
@@ -572,6 +573,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
572573
},
573574
[],
574575
);
576+
const handleDragStart = useCallback(
577+
(event: React.DragEvent<HTMLElement>) => {
578+
if (renamingThreadKey === threadKey) {
579+
event.preventDefault();
580+
return;
581+
}
582+
event.dataTransfer.effectAllowed = "move";
583+
event.dataTransfer.setData("text/plain", thread.id);
584+
useWorkspaceDragStore.getState().setItem({
585+
kind: "thread",
586+
input: serverThreadSurfaceInput(threadRef),
587+
});
588+
},
589+
[renamingThreadKey, thread.id, threadKey, threadRef],
590+
);
591+
const handleDragEnd = useCallback(() => {
592+
useWorkspaceDragStore.getState().clearItem();
593+
}, []);
575594
const handleConfirmArchiveClick = useCallback(
576595
(event: React.MouseEvent<HTMLButtonElement>) => {
577596
event.preventDefault();
@@ -618,7 +637,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
618637
isActive,
619638
isSelected,
620639
})} relative isolate`}
640+
draggable={renamingThreadKey !== threadKey}
621641
onClick={handleRowClick}
642+
onDragEnd={handleDragEnd}
643+
onDragStart={handleDragStart}
622644
onKeyDown={handleRowKeyDown}
623645
onContextMenu={handleRowContextMenu}
624646
>

0 commit comments

Comments
 (0)