Skip to content

Commit 2343d3d

Browse files
committed
Add floating terminal layout support
- add terminal layout setting and contract schema - render the thread terminal as a floating dialog when enabled - switch package scripts to run under Bun
1 parent ada410b commit 2343d3d

7 files changed

Lines changed: 155 additions & 34 deletions

File tree

apps/desktop/src/clientPersistence.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const clientSettings: ClientSettings = {
6161
sidebarProjectSortOrder: "manual",
6262
sidebarThreadSortOrder: "created_at",
6363
timestampFormat: "24-hour",
64+
terminalLayout: "floating",
6465
};
6566

6667
const savedRegistryRecord: PersistedSavedEnvironmentRecord = {

apps/web/src/components/ChatView.tsx

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type ServerProvider,
1212
type ResolvedKeybindingsConfig,
1313
type ScopedThreadRef,
14+
type TerminalLayout,
1415
type ThreadId,
1516
type TurnId,
1617
type KeybindingCommand,
@@ -33,7 +34,7 @@ import {
3334
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3435
import { truncate } from "@t3tools/shared/String";
3536
import { Debouncer } from "@tanstack/react-pacer";
36-
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
37+
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3738
import { useNavigate, useSearch } from "@tanstack/react-router";
3839
import { useShallow } from "zustand/react/shallow";
3940
import { useGitStatus } from "~/lib/gitStatusState";
@@ -102,7 +103,7 @@ import { BranchToolbar } from "./BranchToolbar";
102103
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
103104
import PlanSidebar from "./PlanSidebar";
104105
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
105-
import { ChevronDownIcon } from "lucide-react";
106+
import { ChevronDownIcon, XIcon } from "lucide-react";
106107
import { cn, randomUUID } from "~/lib/utils";
107108
import { stackedThreadToast, toastManager } from "./ui/toast";
108109
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
@@ -418,6 +419,7 @@ interface PersistentThreadTerminalDrawerProps {
418419
newShortcutLabel: string | undefined;
419420
closeShortcutLabel: string | undefined;
420421
keybindings: ResolvedKeybindingsConfig;
422+
terminalLayout: TerminalLayout;
421423
onAddTerminalContext: (selection: TerminalContextSelection) => void;
422424
}
423425

@@ -431,6 +433,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
431433
newShortcutLabel,
432434
closeShortcutLabel,
433435
keybindings,
436+
terminalLayout,
434437
onAddTerminalContext,
435438
}: PersistentThreadTerminalDrawerProps) {
436439
const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
@@ -449,7 +452,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
449452
const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal);
450453
const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal);
451454
const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal);
455+
const storeSetTerminalOpen = useTerminalStateStore((state) => state.setTerminalOpen);
452456
const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
457+
const floatingTerminalTitleId = useId();
453458
const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
454459
const effectiveWorktreePath = useMemo(() => {
455460
if (launchContext !== null) {
@@ -549,39 +554,81 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
549554
},
550555
[onAddTerminalContext, visible],
551556
);
557+
const closeTerminalWindow = useCallback(() => {
558+
storeSetTerminalOpen(threadRef, false);
559+
}, [storeSetTerminalOpen, threadRef]);
552560

553561
if (!project || !terminalState.terminalOpen || !cwd) {
554562
return null;
555563
}
556564

557-
return (
558-
<div className={visible ? undefined : "hidden"}>
559-
<ThreadTerminalDrawer
560-
threadRef={threadRef}
561-
threadId={threadId}
562-
cwd={cwd}
563-
worktreePath={effectiveWorktreePath}
564-
runtimeEnv={runtimeEnv}
565-
visible={visible}
566-
height={terminalState.terminalHeight}
567-
terminalIds={terminalState.terminalIds}
568-
activeTerminalId={terminalState.activeTerminalId}
569-
terminalGroups={terminalState.terminalGroups}
570-
activeTerminalGroupId={terminalState.activeTerminalGroupId}
571-
focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
572-
onSplitTerminal={splitTerminal}
573-
onNewTerminal={createNewTerminal}
574-
splitShortcutLabel={visible ? splitShortcutLabel : undefined}
575-
newShortcutLabel={visible ? newShortcutLabel : undefined}
576-
closeShortcutLabel={visible ? closeShortcutLabel : undefined}
577-
keybindings={keybindings}
578-
onActiveTerminalChange={activateTerminal}
579-
onCloseTerminal={closeTerminal}
580-
onHeightChange={setTerminalHeight}
581-
onAddTerminalContext={handleAddTerminalContext}
582-
/>
583-
</div>
584-
);
565+
const drawer = (
566+
<ThreadTerminalDrawer
567+
threadRef={threadRef}
568+
threadId={threadId}
569+
cwd={cwd}
570+
worktreePath={effectiveWorktreePath}
571+
runtimeEnv={runtimeEnv}
572+
visible={visible}
573+
height={terminalState.terminalHeight}
574+
terminalIds={terminalState.terminalIds}
575+
activeTerminalId={terminalState.activeTerminalId}
576+
terminalGroups={terminalState.terminalGroups}
577+
activeTerminalGroupId={terminalState.activeTerminalGroupId}
578+
focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
579+
onSplitTerminal={splitTerminal}
580+
onNewTerminal={createNewTerminal}
581+
splitShortcutLabel={visible ? splitShortcutLabel : undefined}
582+
newShortcutLabel={visible ? newShortcutLabel : undefined}
583+
closeShortcutLabel={visible ? closeShortcutLabel : undefined}
584+
keybindings={keybindings}
585+
onActiveTerminalChange={activateTerminal}
586+
onCloseTerminal={closeTerminal}
587+
onHeightChange={setTerminalHeight}
588+
onAddTerminalContext={handleAddTerminalContext}
589+
layout={terminalLayout}
590+
/>
591+
);
592+
593+
if (terminalLayout === "floating") {
594+
return (
595+
<div
596+
className={cn(
597+
"fixed inset-0 z-50 bg-black/32 backdrop-blur-sm",
598+
visible ? "grid grid-rows-[1fr_auto_3fr] justify-items-center p-4" : "hidden",
599+
)}
600+
onMouseDown={(event) => {
601+
if (event.target === event.currentTarget) {
602+
closeTerminalWindow();
603+
}
604+
}}
605+
>
606+
<div
607+
role="dialog"
608+
aria-modal="true"
609+
aria-labelledby={floatingTerminalTitleId}
610+
className="w-[min(96vw,72rem)] max-w-[min(96vw,72rem)] overflow-hidden rounded-lg border bg-background p-0 shadow-xl"
611+
>
612+
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/80 px-2">
613+
<h2 id={floatingTerminalTitleId} className="text-xs font-medium leading-none">
614+
Terminal
615+
</h2>
616+
<button
617+
type="button"
618+
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
619+
onClick={closeTerminalWindow}
620+
aria-label="Close terminal window"
621+
>
622+
<XIcon className="size-3.5" />
623+
</button>
624+
</div>
625+
{drawer}
626+
</div>
627+
</div>
628+
);
629+
}
630+
631+
return <div className={visible ? undefined : "hidden"}>{drawer}</div>;
585632
});
586633

587634
export default function ChatView(props: ChatViewProps) {
@@ -3263,6 +3310,7 @@ export default function ChatView(props: ChatViewProps) {
32633310
availableEditors={availableEditors}
32643311
terminalAvailable={activeProject !== undefined}
32653312
terminalOpen={terminalState.terminalOpen}
3313+
terminalLayout={settings.terminalLayout}
32663314
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
32673315
diffToggleShortcutLabel={diffPanelShortcutLabel}
32683316
gitCwd={gitCwd}
@@ -3477,6 +3525,7 @@ export default function ChatView(props: ChatViewProps) {
34773525
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
34783526
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
34793527
keybindings={keybindings}
3528+
terminalLayout={settings.terminalLayout}
34803529
onAddTerminalContext={addTerminalContextToDraft}
34813530
/>
34823531
))}

apps/web/src/components/ThreadTerminalDrawer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci
33
import {
44
type ResolvedKeybindingsConfig,
55
type ScopedThreadRef,
6+
type TerminalLayout,
67
type TerminalEvent,
78
type TerminalSessionSnapshot,
89
type ThreadId,
@@ -20,6 +21,7 @@ import {
2021
} from "react";
2122
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
2223
import { type TerminalContextSelection } from "~/lib/terminalContext";
24+
import { cn } from "~/lib/utils";
2325
import { openInPreferredEditor } from "../editorPreferences";
2426
import {
2527
collectWrappedTerminalLinkLine,
@@ -821,6 +823,7 @@ interface ThreadTerminalDrawerProps {
821823
onHeightChange: (height: number) => void;
822824
onAddTerminalContext: (selection: TerminalContextSelection) => void;
823825
keybindings: ResolvedKeybindingsConfig;
826+
layout?: TerminalLayout;
824827
}
825828

826829
interface TerminalActionButtonProps {
@@ -875,6 +878,7 @@ export default function ThreadTerminalDrawer({
875878
onHeightChange,
876879
onAddTerminalContext,
877880
keybindings,
881+
layout = "docked",
878882
}: ThreadTerminalDrawerProps) {
879883
const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
880884
const [resizeEpoch, setResizeEpoch] = useState(0);
@@ -1111,7 +1115,10 @@ export default function ThreadTerminalDrawer({
11111115

11121116
return (
11131117
<aside
1114-
className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
1118+
className={cn(
1119+
"thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden bg-background",
1120+
layout === "docked" ? "border-t border-border/80" : "border-0",
1121+
)}
11151122
style={{ height: `${drawerHeight}px` }}
11161123
>
11171124
<div

apps/web/src/components/chat/ChatHeader.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type EditorId,
44
type ProjectScript,
55
type ResolvedKeybindingsConfig,
6+
type TerminalLayout,
67
type ThreadId,
78
} from "@t3tools/contracts";
89
import { scopeThreadRef } from "@t3tools/client-runtime";
@@ -31,6 +32,7 @@ interface ChatHeaderProps {
3132
availableEditors: ReadonlyArray<EditorId>;
3233
terminalAvailable: boolean;
3334
terminalOpen: boolean;
35+
terminalLayout: TerminalLayout;
3436
terminalToggleShortcutLabel: string | null;
3537
diffToggleShortcutLabel: string | null;
3638
gitCwd: string | null;
@@ -57,6 +59,7 @@ export const ChatHeader = memo(function ChatHeader({
5759
availableEditors,
5860
terminalAvailable,
5961
terminalOpen,
62+
terminalLayout,
6063
terminalToggleShortcutLabel,
6164
diffToggleShortcutLabel,
6265
gitCwd,
@@ -68,6 +71,9 @@ export const ChatHeader = memo(function ChatHeader({
6871
onToggleTerminal,
6972
onToggleDiff,
7073
}: ChatHeaderProps) {
74+
const terminalSurfaceLabel =
75+
terminalLayout === "floating" ? "terminal window" : "terminal drawer";
76+
7177
return (
7278
<div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
7379
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
@@ -122,7 +128,7 @@ export const ChatHeader = memo(function ChatHeader({
122128
className="shrink-0"
123129
pressed={terminalOpen}
124130
onPressedChange={onToggleTerminal}
125-
aria-label="Toggle terminal drawer"
131+
aria-label={`Toggle ${terminalSurfaceLabel}`}
126132
variant="outline"
127133
size="xs"
128134
disabled={!terminalAvailable}
@@ -135,8 +141,8 @@ export const ChatHeader = memo(function ChatHeader({
135141
{!terminalAvailable
136142
? "Terminal is unavailable until this thread has an active project."
137143
: terminalToggleShortcutLabel
138-
? `Toggle terminal drawer (${terminalToggleShortcutLabel})`
139-
: "Toggle terminal drawer"}
144+
? `Toggle ${terminalSurfaceLabel} (${terminalToggleShortcutLabel})`
145+
: `Toggle ${terminalSurfaceLabel}`}
140146
</TooltipPopup>
141147
</Tooltip>
142148
<Tooltip>

apps/web/src/components/settings/SettingsPanels.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ const TIMESTAMP_FORMAT_LABELS = {
100100
"24-hour": "24-hour",
101101
} as const;
102102

103+
const TERMINAL_LAYOUT_LABELS = {
104+
docked: "Docked",
105+
floating: "Floating",
106+
} as const;
107+
103108
type InstallProviderSettings = {
104109
provider: ProviderKind;
105110
title: string;
@@ -475,6 +480,9 @@ export function useSettingsRestore(onRestored?: () => void) {
475480
...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
476481
? ["Task sidebar"]
477482
: []),
483+
...(settings.terminalLayout !== DEFAULT_UNIFIED_SETTINGS.terminalLayout
484+
? ["Terminal layout"]
485+
: []),
478486
...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
479487
? ["Assistant output"]
480488
: []),
@@ -503,6 +511,7 @@ export function useSettingsRestore(onRestored?: () => void) {
503511
settings.defaultThreadEnvMode,
504512
settings.diffWordWrap,
505513
settings.enableAssistantStreaming,
514+
settings.terminalLayout,
506515
settings.timestampFormat,
507516
theme,
508517
],
@@ -975,6 +984,45 @@ export function GeneralSettingsPanel() {
975984
}
976985
/>
977986

987+
<SettingsRow
988+
title="Terminal layout"
989+
description="Choose whether the terminal opens docked under chat or in a floating window."
990+
resetAction={
991+
settings.terminalLayout !== DEFAULT_UNIFIED_SETTINGS.terminalLayout ? (
992+
<SettingResetButton
993+
label="terminal layout"
994+
onClick={() =>
995+
updateSettings({
996+
terminalLayout: DEFAULT_UNIFIED_SETTINGS.terminalLayout,
997+
})
998+
}
999+
/>
1000+
) : null
1001+
}
1002+
control={
1003+
<Select
1004+
value={settings.terminalLayout}
1005+
onValueChange={(value) => {
1006+
if (value === "docked" || value === "floating") {
1007+
updateSettings({ terminalLayout: value });
1008+
}
1009+
}}
1010+
>
1011+
<SelectTrigger className="w-full sm:w-40" aria-label="Terminal layout">
1012+
<SelectValue>{TERMINAL_LAYOUT_LABELS[settings.terminalLayout]}</SelectValue>
1013+
</SelectTrigger>
1014+
<SelectPopup align="end" alignItemWithTrigger={false}>
1015+
<SelectItem hideIndicator value="docked">
1016+
{TERMINAL_LAYOUT_LABELS.docked}
1017+
</SelectItem>
1018+
<SelectItem hideIndicator value="floating">
1019+
{TERMINAL_LAYOUT_LABELS.floating}
1020+
</SelectItem>
1021+
</SelectPopup>
1022+
</Select>
1023+
}
1024+
/>
1025+
9781026
<SettingsRow
9791027
title="New threads"
9801028
description="Pick the default workspace mode for newly created draft threads."

apps/web/src/localApi.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ describe("wsApi", () => {
541541
sidebarProjectSortOrder: "manual" as const,
542542
sidebarThreadSortOrder: "created_at" as const,
543543
timestampFormat: "24-hour" as const,
544+
terminalLayout: "floating" as const,
544545
};
545546
const getClientSettings = vi.fn().mockResolvedValue({
546547
...clientSettings,
@@ -600,6 +601,7 @@ describe("wsApi", () => {
600601
sidebarProjectSortOrder: "manual" as const,
601602
sidebarThreadSortOrder: "created_at" as const,
602603
timestampFormat: "24-hour" as const,
604+
terminalLayout: "floating" as const,
603605
};
604606

605607
await api.persistence.setClientSettings(clientSettings);

0 commit comments

Comments
 (0)