Skip to content

Commit 9793b4f

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 d1e85c4 commit 9793b4f

6 files changed

Lines changed: 168 additions & 34 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type ServerProvider,
1313
type ResolvedKeybindingsConfig,
1414
type ScopedThreadRef,
15+
type TerminalLayout,
1516
type ThreadId,
1617
type TurnId,
1718
type KeybindingCommand,
@@ -35,7 +36,7 @@ import {
3536
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3637
import { truncate } from "@t3tools/shared/String";
3738
import { Debouncer } from "@tanstack/react-pacer";
38-
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
39+
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3940
import { useNavigate, useSearch } from "@tanstack/react-router";
4041
import { useShallow } from "zustand/react/shallow";
4142
import { useGitStatus } from "~/lib/gitStatusState";
@@ -104,7 +105,7 @@ import { BranchToolbar } from "./BranchToolbar";
104105
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
105106
import PlanSidebar from "./PlanSidebar";
106107
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
107-
import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
108+
import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon, XIcon } from "lucide-react";
108109
import { cn, randomUUID } from "~/lib/utils";
109110
import { stackedThreadToast, toastManager } from "./ui/toast";
110111
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
@@ -438,6 +439,7 @@ interface PersistentThreadTerminalDrawerProps {
438439
newShortcutLabel: string | undefined;
439440
closeShortcutLabel: string | undefined;
440441
keybindings: ResolvedKeybindingsConfig;
442+
terminalLayout: TerminalLayout;
441443
onAddTerminalContext: (selection: TerminalContextSelection) => void;
442444
}
443445

@@ -451,6 +453,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
451453
newShortcutLabel,
452454
closeShortcutLabel,
453455
keybindings,
456+
terminalLayout,
454457
onAddTerminalContext,
455458
}: PersistentThreadTerminalDrawerProps) {
456459
const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
@@ -469,7 +472,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
469472
const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal);
470473
const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal);
471474
const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal);
475+
const storeSetTerminalOpen = useTerminalStateStore((state) => state.setTerminalOpen);
472476
const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
477+
const floatingTerminalTitleId = useId();
473478
const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
474479
const effectiveWorktreePath = useMemo(() => {
475480
if (launchContext !== null) {
@@ -569,39 +574,81 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
569574
},
570575
[onAddTerminalContext, visible],
571576
);
577+
const closeTerminalWindow = useCallback(() => {
578+
storeSetTerminalOpen(threadRef, false);
579+
}, [storeSetTerminalOpen, threadRef]);
572580

573581
if (!project || !terminalState.terminalOpen || !cwd) {
574582
return null;
575583
}
576584

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

607654
export default function ChatView(props: ChatViewProps) {
@@ -3527,6 +3574,7 @@ export default function ChatView(props: ChatViewProps) {
35273574
availableEditors={availableEditors}
35283575
terminalAvailable={activeProject !== undefined}
35293576
terminalOpen={terminalState.terminalOpen}
3577+
terminalLayout={settings.terminalLayout}
35303578
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
35313579
diffToggleShortcutLabel={diffPanelShortcutLabel}
35323580
gitCwd={gitCwd}
@@ -3753,6 +3801,7 @@ export default function ChatView(props: ChatViewProps) {
37533801
newShortcutLabel={newTerminalShortcutLabel ?? undefined}
37543802
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
37553803
keybindings={keybindings}
3804+
terminalLayout={settings.terminalLayout}
37563805
onAddTerminalContext={addTerminalContextToDraft}
37573806
/>
37583807
))}

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: 8 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";
@@ -32,6 +33,7 @@ interface ChatHeaderProps {
3233
availableEditors: ReadonlyArray<EditorId>;
3334
terminalAvailable: boolean;
3435
terminalOpen: boolean;
36+
terminalLayout: TerminalLayout;
3537
terminalToggleShortcutLabel: string | null;
3638
diffToggleShortcutLabel: string | null;
3739
gitCwd: string | null;
@@ -70,6 +72,7 @@ export const ChatHeader = memo(function ChatHeader({
7072
availableEditors,
7173
terminalAvailable,
7274
terminalOpen,
75+
terminalLayout,
7376
terminalToggleShortcutLabel,
7477
diffToggleShortcutLabel,
7578
gitCwd,
@@ -87,6 +90,8 @@ export const ChatHeader = memo(function ChatHeader({
8790
activeThreadEnvironmentId,
8891
primaryEnvironmentId,
8992
});
93+
const terminalSurfaceLabel =
94+
terminalLayout === "floating" ? "terminal window" : "terminal drawer";
9095

9196
return (
9297
<div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
@@ -142,7 +147,7 @@ export const ChatHeader = memo(function ChatHeader({
142147
className="shrink-0"
143148
pressed={terminalOpen}
144149
onPressedChange={onToggleTerminal}
145-
aria-label="Toggle terminal drawer"
150+
aria-label={`Toggle ${terminalSurfaceLabel}`}
146151
variant="outline"
147152
size="xs"
148153
disabled={!terminalAvailable}
@@ -155,8 +160,8 @@ export const ChatHeader = memo(function ChatHeader({
155160
{!terminalAvailable
156161
? "Terminal is unavailable until this thread has an active project."
157162
: terminalToggleShortcutLabel
158-
? `Toggle terminal drawer (${terminalToggleShortcutLabel})`
159-
: "Toggle terminal drawer"}
163+
? `Toggle ${terminalSurfaceLabel} (${terminalToggleShortcutLabel})`
164+
: `Toggle ${terminalSurfaceLabel}`}
160165
</TooltipPopup>
161166
</Tooltip>
162167
<Tooltip>

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,26 @@ const TIMESTAMP_FORMAT_LABELS = {
101101

102102
const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
103103

104+
const TERMINAL_LAYOUT_LABELS = {
105+
docked: "Docked",
106+
floating: "Floating",
107+
} as const;
108+
109+
type InstallProviderSettings = {
110+
provider: ProviderKind;
111+
title: string;
112+
badgeLabel?: string;
113+
binaryPlaceholder: string;
114+
binaryDescription: ReactNode;
115+
serverUrlPlaceholder?: string;
116+
serverUrlDescription?: ReactNode;
117+
serverPasswordPlaceholder?: string;
118+
serverPasswordDescription?: ReactNode;
119+
homePathKey?: "codexHomePath";
120+
homePlaceholder?: string;
121+
homeDescription?: ReactNode;
122+
};
123+
104124
function withoutProviderInstanceKey<V>(
105125
record: Readonly<Record<ProviderInstanceId, V>> | undefined,
106126
key: ProviderInstanceId,
@@ -405,6 +425,9 @@ export function useSettingsRestore(onRestored?: () => void) {
405425
...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
406426
? ["Auto-open task panel"]
407427
: []),
428+
...(settings.terminalLayout !== DEFAULT_UNIFIED_SETTINGS.terminalLayout
429+
? ["Terminal layout"]
430+
: []),
408431
...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
409432
? ["Assistant output"]
410433
: []),
@@ -438,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {
438461
settings.automaticGitFetchInterval,
439462
settings.enableAssistantStreaming,
440463
settings.sidebarThreadPreviewCount,
464+
settings.terminalLayout,
441465
settings.timestampFormat,
442466
theme,
443467
],
@@ -695,6 +719,45 @@ export function GeneralSettingsPanel() {
695719
}
696720
/>
697721

722+
<SettingsRow
723+
title="Terminal layout"
724+
description="Choose whether the terminal opens docked under chat or in a floating window."
725+
resetAction={
726+
settings.terminalLayout !== DEFAULT_UNIFIED_SETTINGS.terminalLayout ? (
727+
<SettingResetButton
728+
label="terminal layout"
729+
onClick={() =>
730+
updateSettings({
731+
terminalLayout: DEFAULT_UNIFIED_SETTINGS.terminalLayout,
732+
})
733+
}
734+
/>
735+
) : null
736+
}
737+
control={
738+
<Select
739+
value={settings.terminalLayout}
740+
onValueChange={(value) => {
741+
if (value === "docked" || value === "floating") {
742+
updateSettings({ terminalLayout: value });
743+
}
744+
}}
745+
>
746+
<SelectTrigger className="w-full sm:w-40" aria-label="Terminal layout">
747+
<SelectValue>{TERMINAL_LAYOUT_LABELS[settings.terminalLayout]}</SelectValue>
748+
</SelectTrigger>
749+
<SelectPopup align="end" alignItemWithTrigger={false}>
750+
<SelectItem hideIndicator value="docked">
751+
{TERMINAL_LAYOUT_LABELS.docked}
752+
</SelectItem>
753+
<SelectItem hideIndicator value="floating">
754+
{TERMINAL_LAYOUT_LABELS.floating}
755+
</SelectItem>
756+
</SelectPopup>
757+
</Select>
758+
}
759+
/>
760+
698761
<SettingsRow
699762
title="New threads"
700763
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
@@ -615,6 +615,7 @@ describe("wsApi", () => {
615615
sidebarThreadSortOrder: "created_at" as const,
616616
sidebarThreadPreviewCount: 6,
617617
timestampFormat: "24-hour" as const,
618+
terminalLayout: "floating" as const,
618619
};
619620
const getClientSettings = vi.fn().mockResolvedValue({
620621
...clientSettings,
@@ -678,6 +679,7 @@ describe("wsApi", () => {
678679
sidebarThreadSortOrder: "created_at" as const,
679680
sidebarThreadPreviewCount: 6,
680681
timestampFormat: "24-hour" as const,
682+
terminalLayout: "floating" as const,
681683
};
682684

683685
await api.persistence.setClientSettings(clientSettings);

0 commit comments

Comments
 (0)