Skip to content

Commit df27669

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 df27669

7 files changed

Lines changed: 152 additions & 33 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: 76 additions & 29 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,
@@ -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";
@@ -178,6 +179,7 @@ import {
178179
import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
179180
import { retainThreadDetailSubscription } from "../environments/runtime/service";
180181
import { RightPanelSheet } from "./RightPanelSheet";
182+
import { Dialog, DialogPopup, DialogTitle } from "./ui/dialog";
181183

182184
const IMAGE_ONLY_BOOTSTRAP_PROMPT =
183185
"[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
@@ -418,6 +420,7 @@ interface PersistentThreadTerminalDrawerProps {
418420
newShortcutLabel: string | undefined;
419421
closeShortcutLabel: string | undefined;
420422
keybindings: ResolvedKeybindingsConfig;
423+
terminalLayout: TerminalLayout;
421424
onAddTerminalContext: (selection: TerminalContextSelection) => void;
422425
}
423426

@@ -431,6 +434,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
431434
newShortcutLabel,
432435
closeShortcutLabel,
433436
keybindings,
437+
terminalLayout,
434438
onAddTerminalContext,
435439
}: PersistentThreadTerminalDrawerProps) {
436440
const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
@@ -449,6 +453,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
449453
const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal);
450454
const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal);
451455
const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal);
456+
const storeSetTerminalOpen = useTerminalStateStore((state) => state.setTerminalOpen);
452457
const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
453458
const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
454459
const effectiveWorktreePath = useMemo(() => {
@@ -549,39 +554,79 @@ 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+
if (!visible) {
595+
return <div className="hidden">{drawer}</div>;
596+
}
597+
598+
return (
599+
<Dialog
600+
open
601+
onOpenChange={(open, eventDetails) => {
602+
if (!open && eventDetails.reason !== "escape-key") {
603+
closeTerminalWindow();
604+
}
605+
}}
606+
>
607+
<DialogPopup
608+
showCloseButton={false}
609+
bottomStickOnMobile={false}
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+
<DialogTitle className="text-xs font-medium leading-none">Terminal</DialogTitle>
614+
<button
615+
type="button"
616+
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
617+
onClick={closeTerminalWindow}
618+
aria-label="Close terminal window"
619+
>
620+
<XIcon className="size-3.5" />
621+
</button>
622+
</div>
623+
{drawer}
624+
</DialogPopup>
625+
</Dialog>
626+
);
627+
}
628+
629+
return <div className={visible ? undefined : "hidden"}>{drawer}</div>;
585630
});
586631

587632
export default function ChatView(props: ChatViewProps) {
@@ -3263,6 +3308,7 @@ export default function ChatView(props: ChatViewProps) {
32633308
availableEditors={availableEditors}
32643309
terminalAvailable={activeProject !== undefined}
32653310
terminalOpen={terminalState.terminalOpen}
3311+
terminalLayout={settings.terminalLayout}
32663312
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
32673313
diffToggleShortcutLabel={diffPanelShortcutLabel}
32683314
gitCwd={gitCwd}
@@ -3477,6 +3523,7 @@ export default function ChatView(props: ChatViewProps) {
34773523
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
34783524
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
34793525
keybindings={keybindings}
3526+
terminalLayout={settings.terminalLayout}
34803527
onAddTerminalContext={addTerminalContextToDraft}
34813528
/>
34823529
))}

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);

packages/contracts/src/settings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export const SidebarProjectGroupingMode = Schema.Literals([
3030
export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;
3131
export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";
3232

33+
export const TerminalLayout = Schema.Literals(["docked", "floating"]);
34+
export type TerminalLayout = typeof TerminalLayout.Type;
35+
export const DEFAULT_TERMINAL_LAYOUT: TerminalLayout = "docked";
36+
3337
export const ClientSettingsSchema = Schema.Struct({
3438
autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
3539
confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
@@ -57,6 +61,9 @@ export const ClientSettingsSchema = Schema.Struct({
5761
timestampFormat: TimestampFormat.pipe(
5862
Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
5963
),
64+
terminalLayout: TerminalLayout.pipe(
65+
Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_LAYOUT)),
66+
),
6067
});
6168
export type ClientSettings = typeof ClientSettingsSchema.Type;
6269

@@ -263,5 +270,6 @@ export const ClientSettingsPatch = Schema.Struct({
263270
sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
264271
sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
265272
timestampFormat: Schema.optionalKey(TimestampFormat),
273+
terminalLayout: Schema.optionalKey(TerminalLayout),
266274
});
267275
export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;

0 commit comments

Comments
 (0)