mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Dock Buzz Term within channel workspace (#4724)
## Summary - replace Buzz Term's full-app takeover with a resizable bottom dock inside the channel content surface - add a discoverable channel-header button plus hide and maximize/restore controls - create PTYs lazily and keep separate, persistent terminal workspaces per channel - capture immutable channel/thread context on every terminal session ## Multiple-channel behavior The dock is a single surface, but its tabs are partitioned by channel. Switching channels swaps to that channel's sessions without terminating background PTYs; returning restores them. New tabs capture the currently visible channel/thread context. ## Verification At commit `7ca087f8e08c80528387684364a65bf4ccd6315f`: - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 4,129 passed - pre-push repository hooks — desktop check/test, Tauri checks, terminal Rust suites all passed --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Co-authored-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -17,12 +17,6 @@ type AppHuddleShellProps = {
|
||||
onShowHuddleInMainApp: (ephemeralChannelId: string) => void;
|
||||
onViewHuddleChannel: (ephemeralChannelId: string) => void;
|
||||
onVisibilityChange: (visible: boolean) => void;
|
||||
/**
|
||||
* Terminal substrate layer. Rendered behind the app surface (which carries
|
||||
* z-10) so the ⌘J handoff can reveal it by fading the surface above. Not
|
||||
* mounted in the dedicated Huddle room window.
|
||||
*/
|
||||
terminal?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AppHuddleShell({
|
||||
@@ -37,7 +31,6 @@ export function AppHuddleShell({
|
||||
onShowHuddleInMainApp,
|
||||
onViewHuddleChannel,
|
||||
onVisibilityChange,
|
||||
terminal,
|
||||
}: AppHuddleShellProps) {
|
||||
return (
|
||||
<HuddleProvider
|
||||
@@ -55,7 +48,6 @@ export function AppHuddleShell({
|
||||
data-huddle-open={isDrawerOpen}
|
||||
data-huddle-window={isRoom}
|
||||
>
|
||||
{isRoom ? null : terminal}
|
||||
<div
|
||||
className={cn(
|
||||
"buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",
|
||||
|
||||
@@ -774,7 +774,6 @@ export function AppShell() {
|
||||
onShowHuddleInMainApp={showHuddleInMainApp}
|
||||
onViewHuddleChannel={viewHuddleChannel}
|
||||
onVisibilityChange={handleHuddleVisibilityChange}
|
||||
terminal={<TerminalBootstrap {...terminalContext} />}
|
||||
>
|
||||
{hasCommunityRail && !isHuddleRoom ? (
|
||||
<CommunityRail
|
||||
@@ -942,6 +941,7 @@ export function AppShell() {
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
terminal={<TerminalBootstrap {...terminalContext} />}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShellChannelSurface>
|
||||
|
||||
@@ -11,6 +11,7 @@ type AppShellChannelSurfaceProps = {
|
||||
isHuddleRoom: boolean;
|
||||
isHuddleRoomStarting: boolean;
|
||||
mainInsetRef: React.RefObject<HTMLElement | null>;
|
||||
terminal?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AppShellChannelSurface({
|
||||
@@ -18,6 +19,7 @@ export function AppShellChannelSurface({
|
||||
isHuddleRoom,
|
||||
isHuddleRoomStarting,
|
||||
mainInsetRef,
|
||||
terminal,
|
||||
}: AppShellChannelSurfaceProps) {
|
||||
return (
|
||||
<MainInsetProvider mainInsetRef={mainInsetRef}>
|
||||
@@ -34,7 +36,7 @@ export function AppShellChannelSurface({
|
||||
style={chromeCssVarDefaults as React.CSSProperties}
|
||||
>
|
||||
{isHuddleRoom && !isHuddleRoomStarting ? <HuddleRoomHeader /> : null}
|
||||
<BuzzTheme.ContentSurface unframed={isHuddleRoom}>
|
||||
<BuzzTheme.ContentSurface terminal={terminal} unframed={isHuddleRoom}>
|
||||
{isHuddleRoomStarting ? <HuddleStartingView /> : children}
|
||||
</BuzzTheme.ContentSurface>
|
||||
</SidebarInset>
|
||||
|
||||
@@ -23,8 +23,10 @@ export function GradientLayer() {
|
||||
export function ContentSurface({
|
||||
children,
|
||||
unframed = false,
|
||||
terminal,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
terminal?: ReactNode;
|
||||
/** Used by dedicated huddle windows, which should not resemble app cards. */
|
||||
unframed?: boolean;
|
||||
}) {
|
||||
@@ -38,7 +40,12 @@ export function ContentSurface({
|
||||
data-buzz-content-surface
|
||||
data-buzz-content-unframed={unframed ? true : undefined}
|
||||
>
|
||||
{children}
|
||||
<div className="buzz-content-primary flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
<div className="buzz-terminal-dock-host" data-terminal-dock>
|
||||
{terminal}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LogIn } from "lucide-react";
|
||||
import { LogIn, SquareTerminal } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
@@ -17,6 +17,10 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
toggleTerminalPanel,
|
||||
useTerminalPanel,
|
||||
} from "@/features/terminal/terminalPanelStore";
|
||||
|
||||
const DM_HEADER_AVATAR_SIZE = 32;
|
||||
const DM_HEADER_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry(
|
||||
@@ -74,7 +78,22 @@ export function ChannelScreenHeader({
|
||||
!activeChannel.archivedAt &&
|
||||
onJoinChannel;
|
||||
|
||||
const actions = activeChannel ? (
|
||||
const terminalPanel = useTerminalPanel();
|
||||
const terminalButton = activeChannel ? (
|
||||
<Button
|
||||
aria-label={
|
||||
terminalPanel.mode === "closed" ? "Open Buzz Term" : "Hide Buzz Term"
|
||||
}
|
||||
onClick={toggleTerminalPanel}
|
||||
size="icon"
|
||||
title="Buzz Term (⌘J)"
|
||||
type="button"
|
||||
variant={terminalPanel.mode === "closed" ? "outline" : "secondary"}
|
||||
>
|
||||
<SquareTerminal />
|
||||
</Button>
|
||||
) : null;
|
||||
const channelActions = activeChannel ? (
|
||||
showJoinButton ? (
|
||||
<Button
|
||||
disabled={isJoining}
|
||||
@@ -97,6 +116,12 @@ export function ChannelScreenHeader({
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
const actions = activeChannel ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{terminalButton}
|
||||
{channelActions}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!showHeaderContent) {
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
import { after, afterEach, before, beforeEach, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { setTerminalPanelMode } from "./terminalPanelStore.ts";
|
||||
|
||||
// `pretendToBeVisual` is what gives jsdom requestAnimationFrame. The banner's
|
||||
// animation loop needs it; without it the loop silently never runs and every
|
||||
@@ -18,6 +19,8 @@ let resizeCallback;
|
||||
let canvasWidth = 840;
|
||||
let attachResolver = null;
|
||||
let deferResizes = false;
|
||||
let deferClose = false;
|
||||
let closeResolver = null;
|
||||
const pendingResizes = [];
|
||||
|
||||
before(async () => {
|
||||
@@ -33,7 +36,10 @@ before(async () => {
|
||||
dom.window.localStorage.setItem("buzz-follow-system", "false");
|
||||
dom.window.isTauri = true;
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: false,
|
||||
// This suite exercises bootstrap/IPC behavior, not banner motion. Keeping
|
||||
// animation disabled avoids competing perpetual rAF loops under the full
|
||||
// parallel test runner; motion itself is covered by TerminalSubstrate.
|
||||
matches: true,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
@@ -79,9 +85,12 @@ before(async () => {
|
||||
calls.push({ command, args });
|
||||
if (command === "terminal_attach") {
|
||||
channel = args.onFrame;
|
||||
const sessionNumber = calls.filter(
|
||||
({ command }) => command === "terminal_attach",
|
||||
).length;
|
||||
const response = {
|
||||
sessionId: "session-1",
|
||||
subscriptionId: "subscription-1",
|
||||
sessionId: `session-${sessionNumber}`,
|
||||
subscriptionId: `subscription-${sessionNumber}`,
|
||||
viewport: { columns: 100, generation: 0, screenLines: 24 },
|
||||
};
|
||||
return attachResolver
|
||||
@@ -101,6 +110,11 @@ before(async () => {
|
||||
pendingResizes.push(() => resolve(value));
|
||||
});
|
||||
}
|
||||
if (command === "terminal_close" && deferClose) {
|
||||
return new Promise((resolve) => {
|
||||
closeResolver = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
transformCallback(callback) {
|
||||
@@ -115,11 +129,17 @@ before(async () => {
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
afterEach(() => {
|
||||
beforeEach(() => setTerminalPanelMode("docked"));
|
||||
afterEach(async () => {
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
setTerminalPanelMode("closed");
|
||||
calls.length = 0;
|
||||
canvasWidth = 840;
|
||||
attachResolver = null;
|
||||
deferResizes = false;
|
||||
deferClose = false;
|
||||
closeResolver = null;
|
||||
pendingResizes.length = 0;
|
||||
});
|
||||
|
||||
@@ -172,6 +192,9 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
const sessionNumber = calls.filter(
|
||||
({ command }) => command === "terminal_attach",
|
||||
).length;
|
||||
const frameMessage = {
|
||||
type: "frame",
|
||||
payload: {
|
||||
@@ -181,7 +204,7 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame
|
||||
full: true,
|
||||
rows: [],
|
||||
sequence: 7,
|
||||
subscriptionId: "subscription-1",
|
||||
subscriptionId: `subscription-${sessionNumber}`,
|
||||
viewport: { columns: 100, generation: 0, screenLines: 24 },
|
||||
},
|
||||
};
|
||||
@@ -195,8 +218,8 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame
|
||||
calls.find(({ command }) => command === "terminal_ack").args,
|
||||
{
|
||||
sequence: 7,
|
||||
sessionId: "session-1",
|
||||
subscriptionId: "subscription-1",
|
||||
sessionId: `session-${sessionNumber}`,
|
||||
subscriptionId: `subscription-${sessionNumber}`,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -300,11 +323,6 @@ test("opening a tab keeps terminal ownership while its attachment is pending", a
|
||||
await Promise.resolve();
|
||||
});
|
||||
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||
const chord = { bubbles: true, code: "KeyJ", metaKey: true };
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", chord));
|
||||
window.dispatchEvent(new KeyboardEvent("keyup", chord));
|
||||
});
|
||||
await waitFor(() =>
|
||||
assert.equal(substrate.dataset.terminalOwner, "terminal"),
|
||||
);
|
||||
@@ -323,7 +341,124 @@ test("opening a tab keeps terminal ownership while its attachment is pending", a
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("a successful close removes the tab even if the exit event is lost", async () => {
|
||||
test("restoring a channel resizes its PTY to the current dock viewport", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { act, render, waitFor } = await import("@testing-library/react");
|
||||
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||
|
||||
const props = (channelId, channelName) => ({
|
||||
channelId,
|
||||
channelName,
|
||||
npub: "npub1owner",
|
||||
relayUrl: "wss://relay.example",
|
||||
threadId: null,
|
||||
});
|
||||
const tree = (channelId, channelName) =>
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement(TerminalBootstrap, props(channelId, channelName)),
|
||||
);
|
||||
const view = render(tree("channel-a", "alpha"));
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
calls.some(
|
||||
({ command, args }) =>
|
||||
command === "terminal_attach" &&
|
||||
args.request.channelId === "channel-a",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
view.rerender(tree("channel-b", "beta"));
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
calls.some(
|
||||
({ command, args }) =>
|
||||
command === "terminal_attach" &&
|
||||
args.request.channelId === "channel-b",
|
||||
),
|
||||
),
|
||||
);
|
||||
canvasWidth = 1_680;
|
||||
await act(async () => resizeCallback());
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
calls.some(
|
||||
({ command, args }) =>
|
||||
command === "terminal_resize" &&
|
||||
args.sessionId === "session-2" &&
|
||||
args.columns === 200,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
view.rerender(tree("channel-a", "alpha"));
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
calls.some(
|
||||
({ command, args }) =>
|
||||
command === "terminal_resize" &&
|
||||
args.sessionId === "session-1" &&
|
||||
args.columns === 200,
|
||||
),
|
||||
),
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("closing a tab while attach is pending closes the eventual session", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { act, fireEvent, render, waitFor } = await import(
|
||||
"@testing-library/react"
|
||||
);
|
||||
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||
|
||||
attachResolver = () => {};
|
||||
const view = render(
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement(TerminalBootstrap, {
|
||||
channelId: "channel-1",
|
||||
channelName: "general",
|
||||
npub: "npub1owner",
|
||||
relayUrl: "wss://relay.example",
|
||||
threadId: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => assert.equal(typeof attachResolver, "function"));
|
||||
await waitFor(() =>
|
||||
assert.ok(view.queryByRole("tab", { name: /Terminal 1/ })),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(view.getByLabelText("Close SHELL"));
|
||||
setTerminalPanelMode("closed");
|
||||
});
|
||||
await waitFor(() => assert.equal(view.queryByRole("tab"), null));
|
||||
assert.equal(
|
||||
calls.some(({ command }) => command === "terminal_close"),
|
||||
false,
|
||||
"a not-yet-attached session cannot be closed by backend id",
|
||||
);
|
||||
|
||||
await act(async () => attachResolver());
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
calls.some(
|
||||
({ command, args }) =>
|
||||
command === "terminal_close" && args.sessionId === "session-1",
|
||||
),
|
||||
),
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("closing removes the tab before native shutdown resolves", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { fireEvent, render, waitFor } = await import("@testing-library/react");
|
||||
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||
@@ -349,14 +484,19 @@ test("a successful close removes the tab even if the exit event is lost", async
|
||||
await waitFor(() =>
|
||||
assert.ok(calls.some(({ command }) => command === "terminal_attach")),
|
||||
);
|
||||
await waitFor(() => assert.ok(view.queryByRole("tab", { name: /SHELL/ })));
|
||||
await waitFor(() =>
|
||||
assert.ok(view.queryByRole("tab", { name: /Terminal 1/ })),
|
||||
);
|
||||
|
||||
deferClose = true;
|
||||
fireEvent.click(view.getByLabelText("Close SHELL"));
|
||||
|
||||
await waitFor(() =>
|
||||
assert.ok(calls.some(({ command }) => command === "terminal_close")),
|
||||
);
|
||||
await waitFor(() => assert.equal(view.queryByRole("tab"), null));
|
||||
assert.equal(typeof closeResolver, "function");
|
||||
closeResolver();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
@@ -423,3 +563,43 @@ test("wheel deltas reach terminal_scroll with the DOM sign intact", async () =>
|
||||
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("a non-channel route closes the panel and ignores the terminal shortcut", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { act, render, waitFor } = await import("@testing-library/react");
|
||||
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||
const { getTerminalPanelSnapshotForTests } = await import(
|
||||
"./terminalPanelStore.ts"
|
||||
);
|
||||
|
||||
setTerminalPanelMode("docked");
|
||||
const view = render(
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement(TerminalBootstrap, {
|
||||
channelId: null,
|
||||
channelName: null,
|
||||
npub: "npub1owner",
|
||||
relayUrl: "wss://relay.example",
|
||||
threadId: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"),
|
||||
);
|
||||
|
||||
const chord = {
|
||||
bubbles: true,
|
||||
code: "KeyJ",
|
||||
metaKey: true,
|
||||
};
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", chord));
|
||||
window.dispatchEvent(new KeyboardEvent("keyup", chord));
|
||||
});
|
||||
assert.equal(getTerminalPanelSnapshotForTests().mode, "closed");
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
TerminalSubstrate,
|
||||
type TerminalViewportSize,
|
||||
} from "./TerminalSubstrate";
|
||||
import {
|
||||
setTerminalPanelMode,
|
||||
setTerminalSessionChannels,
|
||||
toggleTerminalPanel,
|
||||
useTerminalPanel,
|
||||
} from "./terminalPanelStore";
|
||||
|
||||
type TerminalContext = {
|
||||
channelId: string;
|
||||
@@ -27,6 +33,7 @@ type Session = {
|
||||
frame: TerminalFrameMessage | undefined;
|
||||
title: string;
|
||||
closing: boolean;
|
||||
context: TerminalContext;
|
||||
};
|
||||
|
||||
const INITIAL_SIZE: TerminalViewportSize = {
|
||||
@@ -68,13 +75,98 @@ export function TerminalBootstrap({
|
||||
const mountedRef = React.useRef(true);
|
||||
const sizeRef = React.useRef(INITIAL_SIZE);
|
||||
const resizeChainRef = React.useRef(Promise.resolve());
|
||||
const connectionSizesRef = React.useRef(
|
||||
new WeakMap<TerminalConnection, TerminalViewportSize>(),
|
||||
);
|
||||
const closedSessionKeysRef = React.useRef(new Set<string>());
|
||||
const [sessions, setSessions] = React.useState<Session[]>([]);
|
||||
const [activeKey, setActiveKey] = React.useState<string | null>(null);
|
||||
const [available, setAvailable] = React.useState(() => isTauri());
|
||||
const panel = useTerminalPanel();
|
||||
const [renderedMode, setRenderedMode] = React.useState<
|
||||
"docked" | "maximized"
|
||||
>(panel.mode === "maximized" ? "maximized" : "docked");
|
||||
const [panelVisible, setPanelVisible] = React.useState(
|
||||
panel.mode !== "closed",
|
||||
);
|
||||
const [panelMounted, setPanelMounted] = React.useState(
|
||||
panel.mode !== "closed",
|
||||
);
|
||||
const [splashPending, setSplashPending] = React.useState(true);
|
||||
const [viewportReportingEnabled, setViewportReportingEnabled] =
|
||||
React.useState(panel.mode !== "closed");
|
||||
const previousPanelModeRef = React.useRef(panel.mode);
|
||||
const acknowledgedSequenceRef = React.useRef(new Map<string, number>());
|
||||
const sessionsRef = React.useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
React.useEffect(() => {
|
||||
const previousMode = previousPanelModeRef.current;
|
||||
if (previousMode === panel.mode) return;
|
||||
previousPanelModeRef.current = panel.mode;
|
||||
setViewportReportingEnabled(false);
|
||||
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
let timeout = 0;
|
||||
if (panel.mode !== "closed") {
|
||||
setRenderedMode(panel.mode);
|
||||
setPanelMounted(true);
|
||||
if (previousMode === "closed") {
|
||||
// Give the collapsed substrate a painted frame before expanding it.
|
||||
// A single rAF can still be batched into the mount commit by React.
|
||||
setPanelVisible(false);
|
||||
firstFrame = window.requestAnimationFrame(() => {
|
||||
secondFrame = window.requestAnimationFrame(() =>
|
||||
setPanelVisible(true),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setPanelVisible(true);
|
||||
}
|
||||
// Resizing the PTY through every animation frame causes shell reflow and
|
||||
// leaves transient filler rows in the scrollback. Publish only the final
|
||||
// settled viewport.
|
||||
timeout = window.setTimeout(() => setViewportReportingEnabled(true), 200);
|
||||
} else {
|
||||
setPanelVisible(false);
|
||||
timeout = window.setTimeout(() => setPanelMounted(false), 180);
|
||||
}
|
||||
return () => {
|
||||
window.cancelAnimationFrame(firstFrame);
|
||||
window.cancelAnimationFrame(secondFrame);
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [panel.mode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context && panel.mode !== "closed") setTerminalPanelMode("closed");
|
||||
}, [context, panel.mode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const toggle = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!context ||
|
||||
panel.mode !== "closed" ||
|
||||
event.code !== "KeyJ" ||
|
||||
(!event.metaKey && !event.ctrlKey) ||
|
||||
event.altKey ||
|
||||
event.shiftKey ||
|
||||
event.isComposing
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (event.type === "keyup") toggleTerminalPanel();
|
||||
};
|
||||
window.addEventListener("keydown", toggle, true);
|
||||
window.addEventListener("keyup", toggle, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", toggle, true);
|
||||
window.removeEventListener("keyup", toggle, true);
|
||||
};
|
||||
}, [context, panel.mode]);
|
||||
|
||||
const fail = React.useCallback((error: unknown) => {
|
||||
report(error);
|
||||
setAvailable(false);
|
||||
@@ -102,6 +194,7 @@ export function TerminalBootstrap({
|
||||
frame: undefined,
|
||||
title: "SHELL",
|
||||
closing: false,
|
||||
context: spawnContext,
|
||||
};
|
||||
setSessions((current) => [...current, initial]);
|
||||
setActiveKey(key);
|
||||
@@ -144,6 +237,8 @@ export function TerminalBootstrap({
|
||||
)
|
||||
.then((connection) => {
|
||||
if (!mountedRef.current) return connection.detach();
|
||||
if (closedSessionKeysRef.current.delete(key)) return connection.close();
|
||||
connectionSizesRef.current.set(connection, size);
|
||||
update((session) => ({ ...session, connection }));
|
||||
if (sizeRef.current !== size) {
|
||||
const currentSize = sizeRef.current;
|
||||
@@ -155,20 +250,49 @@ export function TerminalBootstrap({
|
||||
currentSize.pixelWidth,
|
||||
currentSize.pixelHeight,
|
||||
);
|
||||
connectionSizesRef.current.set(connection, currentSize);
|
||||
await connection.viewportReady(viewport);
|
||||
})
|
||||
.catch(fail);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (closedSessionKeysRef.current.delete(key)) return;
|
||||
removeSession(key);
|
||||
fail(error);
|
||||
});
|
||||
}, [available, fail, removeSession]);
|
||||
|
||||
const contextChannelId = context?.channelId ?? null;
|
||||
const channelSessions = React.useMemo(
|
||||
() =>
|
||||
contextChannelId
|
||||
? sessions.filter(
|
||||
(session) => session.context.channelId === contextChannelId,
|
||||
)
|
||||
: [],
|
||||
[contextChannelId, sessions],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (available && context && sessions.length === 0) createSession();
|
||||
}, [available, context, createSession, sessions.length]);
|
||||
setTerminalSessionChannels(
|
||||
sessions.map((session) => session.context.channelId),
|
||||
);
|
||||
}, [sessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (panel.mode === "closed" || !available || !context) return;
|
||||
if (channelSessions.length === 0) createSession();
|
||||
else if (!channelSessions.some((session) => session.key === activeKey))
|
||||
setActiveKey(channelSessions.at(-1)?.key ?? null);
|
||||
}, [
|
||||
activeKey,
|
||||
available,
|
||||
channelSessions,
|
||||
context,
|
||||
createSession,
|
||||
panel.mode,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -180,8 +304,34 @@ export function TerminalBootstrap({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const active = sessions.find((session) => session.key === activeKey) ?? null;
|
||||
const active =
|
||||
channelSessions.find((session) => session.key === activeKey) ??
|
||||
channelSessions.at(-1) ??
|
||||
null;
|
||||
|
||||
React.useEffect(() => {
|
||||
const connection = active?.connection;
|
||||
if (!connection) return;
|
||||
const size = sizeRef.current;
|
||||
if (connectionSizesRef.current.get(connection) === size) return;
|
||||
resizeChainRef.current = resizeChainRef.current
|
||||
.then(async () => {
|
||||
const viewport = await connection.resize(
|
||||
size.columns,
|
||||
size.rows,
|
||||
size.pixelWidth,
|
||||
size.pixelHeight,
|
||||
);
|
||||
connectionSizesRef.current.set(connection, size);
|
||||
await connection.viewportReady(viewport);
|
||||
})
|
||||
.catch(fail);
|
||||
}, [active?.connection, fail]);
|
||||
|
||||
const send = (operation: Promise<void> | undefined) => operation?.catch(fail);
|
||||
const handleSplashStarted = React.useCallback(() => {
|
||||
setSplashPending(false);
|
||||
}, []);
|
||||
|
||||
const handleSize = React.useCallback(
|
||||
(size: TerminalViewportSize) => {
|
||||
@@ -198,6 +348,7 @@ export function TerminalBootstrap({
|
||||
size.pixelWidth,
|
||||
size.pixelHeight,
|
||||
);
|
||||
connectionSizesRef.current.set(connection, size);
|
||||
await connection.viewportReady(viewport);
|
||||
})
|
||||
.catch(fail);
|
||||
@@ -205,14 +356,24 @@ export function TerminalBootstrap({
|
||||
[activeKey, fail],
|
||||
);
|
||||
|
||||
if (!panelMounted) return null;
|
||||
|
||||
return (
|
||||
<TerminalSubstrate
|
||||
bracketedPaste={active?.frame?.bracketedPaste ?? false}
|
||||
channelName={channelName}
|
||||
enabled={available && Boolean(active)}
|
||||
channelName={active?.context.channelName ?? channelName}
|
||||
enabled={available && Boolean(context)}
|
||||
mode={renderedMode}
|
||||
visible={panelVisible}
|
||||
onHide={() => setTerminalPanelMode("closed")}
|
||||
onModeChange={setTerminalPanelMode}
|
||||
onToggle={toggleTerminalPanel}
|
||||
focusReportingEnabled={active?.frame?.focusReporting ?? false}
|
||||
frame={active?.frame}
|
||||
sessionFrames={sessions.flatMap((session) =>
|
||||
viewportReportingEnabled={viewportReportingEnabled}
|
||||
showSplash={splashPending}
|
||||
onSplashStarted={handleSplashStarted}
|
||||
sessionFrames={channelSessions.flatMap((session) =>
|
||||
session.frame ? [{ sessionId: session.key, frame: session.frame }] : [],
|
||||
)}
|
||||
onCloseSession={(key) => {
|
||||
@@ -225,6 +386,7 @@ export function TerminalBootstrap({
|
||||
(session) => session.key === key,
|
||||
)?.connection;
|
||||
if (!connection) {
|
||||
closedSessionKeysRef.current.add(key);
|
||||
removeSession(key);
|
||||
return;
|
||||
}
|
||||
@@ -263,12 +425,14 @@ export function TerminalBootstrap({
|
||||
send(active?.connection?.focus(focused))
|
||||
}
|
||||
onViewportSize={handleSize}
|
||||
sessions={sessions.map((session) => ({
|
||||
active: session.key === activeKey,
|
||||
closing: session.closing,
|
||||
id: session.key,
|
||||
title: session.title,
|
||||
}))}
|
||||
sessions={channelSessions
|
||||
.filter((session) => !session.closing)
|
||||
.map((session) => ({
|
||||
active: session.key === activeKey,
|
||||
closing: session.closing,
|
||||
id: session.key,
|
||||
title: session.title,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ before(async () => {
|
||||
playbackRate: 1,
|
||||
reverse() {},
|
||||
});
|
||||
dom.window.HTMLElement.prototype.setPointerCapture = () => {};
|
||||
({ act, cleanup, fireEvent, render, waitFor } = await import(
|
||||
"@testing-library/react"
|
||||
));
|
||||
@@ -232,6 +233,89 @@ test("tab actions restore terminal input focus", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("drag resize batches visual updates and commits state only on release", async () => {
|
||||
const { view } = fixture({ mode: "docked" });
|
||||
await ready(view);
|
||||
const substrate = view.container.querySelector(".buzz-terminal-substrate");
|
||||
const handle = view.getByLabelText("Resize Buzz Term");
|
||||
|
||||
fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 });
|
||||
fireEvent.pointerMove(handle, { clientY: 400, pointerId: 2 });
|
||||
fireEvent.pointerUp(handle, { clientY: 400, pointerId: 2 });
|
||||
assert.equal(substrate.dataset.terminalResizing, "true");
|
||||
fireEvent.pointerMove(handle, { clientY: 460, pointerId: 1 });
|
||||
fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 });
|
||||
assert.equal(substrate.dataset.terminalResizing, "true");
|
||||
assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), null);
|
||||
|
||||
await waitFor(() => assert.equal(substrate.style.height, "380px"));
|
||||
fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 });
|
||||
assert.equal(substrate.dataset.terminalResizing, undefined);
|
||||
assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), "380");
|
||||
});
|
||||
|
||||
test("drag resize repaints the canvas without reporting PTY geometry until release", async () => {
|
||||
let canvasHeight = 280;
|
||||
const viewportSizes = [];
|
||||
dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({
|
||||
bottom: canvasHeight,
|
||||
height: canvasHeight,
|
||||
left: 0,
|
||||
right: 940.8,
|
||||
top: 0,
|
||||
width: 940.8,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
});
|
||||
const { view } = fixture({
|
||||
mode: "docked",
|
||||
onViewportSize(size) {
|
||||
viewportSizes.push(size);
|
||||
},
|
||||
});
|
||||
await ready(view);
|
||||
const canvas = view.container.querySelector(
|
||||
".buzz-terminal-viewport > canvas:not(.buzz-terminal-welcome)",
|
||||
);
|
||||
const handle = view.getByLabelText("Resize Buzz Term");
|
||||
await waitFor(() => assert.equal(canvas.height, 280));
|
||||
const reportsBeforeDrag = viewportSizes.length;
|
||||
|
||||
fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 });
|
||||
canvasHeight = 340;
|
||||
fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 });
|
||||
|
||||
await waitFor(() => assert.equal(canvas.height, 340));
|
||||
assert.equal(
|
||||
viewportSizes.length,
|
||||
reportsBeforeDrag,
|
||||
"visual repaint must not resize the PTY during drag",
|
||||
);
|
||||
|
||||
fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 });
|
||||
await waitFor(() => assert.equal(viewportSizes.at(-1).pixelHeight, 340));
|
||||
});
|
||||
|
||||
test("unmount cancels a queued drag update", async () => {
|
||||
const { view } = fixture({ mode: "docked" });
|
||||
await ready(view);
|
||||
const handle = view.getByLabelText("Resize Buzz Term");
|
||||
const previousHeight = handle.closest(".buzz-terminal-substrate").style
|
||||
.height;
|
||||
|
||||
fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 });
|
||||
fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 });
|
||||
view.unmount();
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
|
||||
assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), null);
|
||||
assert.equal(
|
||||
handle.closest(".buzz-terminal-substrate").style.height,
|
||||
previousHeight,
|
||||
);
|
||||
});
|
||||
|
||||
const EMPTY_FRAME = {
|
||||
cursor: { column: 0, line: 0, visible: false },
|
||||
full: false,
|
||||
@@ -275,22 +359,77 @@ async function reveal(view) {
|
||||
);
|
||||
}
|
||||
|
||||
test("spawn-time output before the first reveal keeps the welcome overlay", async () => {
|
||||
const subject = fixture({ frame: EMPTY_FRAME });
|
||||
test("the first settled reveal runs one bounded splash", async () => {
|
||||
let starts = 0;
|
||||
const subject = fixture({
|
||||
frame: EMPTY_FRAME,
|
||||
onSplashStarted() {
|
||||
starts += 1;
|
||||
},
|
||||
showSplash: true,
|
||||
viewportReportingEnabled: false,
|
||||
visible: true,
|
||||
});
|
||||
await ready(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
await expectWelcome(subject.view, false);
|
||||
|
||||
subject.rerender({ frame: VISIBLE_FRAME });
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
await reveal(subject.view);
|
||||
subject.rerender({
|
||||
frame: EMPTY_FRAME,
|
||||
onSplashStarted() {
|
||||
starts += 1;
|
||||
},
|
||||
showSplash: true,
|
||||
viewportReportingEnabled: true,
|
||||
visible: true,
|
||||
});
|
||||
await expectWelcome(subject.view, true);
|
||||
assert.equal(starts, 1);
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 2_550)));
|
||||
await expectWelcome(subject.view, false);
|
||||
});
|
||||
|
||||
test("the first keystroke dismisses the welcome overlay", async () => {
|
||||
const subject = fixture({ frame: VISIBLE_FRAME });
|
||||
test("later reveals do not replay a consumed splash", async () => {
|
||||
const subject = fixture({
|
||||
frame: EMPTY_FRAME,
|
||||
showSplash: true,
|
||||
viewportReportingEnabled: true,
|
||||
visible: true,
|
||||
});
|
||||
await ready(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
subject.rerender({ frame: EMPTY_FRAME, showSplash: false, visible: false });
|
||||
await expectWelcome(subject.view, false);
|
||||
subject.rerender({ frame: EMPTY_FRAME, showSplash: false, visible: true });
|
||||
await expectWelcome(subject.view, false);
|
||||
});
|
||||
|
||||
test("a consumed splash stays absent after substrate remount", async () => {
|
||||
const first = fixture({
|
||||
frame: EMPTY_FRAME,
|
||||
showSplash: true,
|
||||
visible: true,
|
||||
});
|
||||
await ready(first.view);
|
||||
await expectWelcome(first.view, true);
|
||||
first.view.unmount();
|
||||
|
||||
const second = fixture({
|
||||
frame: EMPTY_FRAME,
|
||||
showSplash: false,
|
||||
visible: true,
|
||||
});
|
||||
await ready(second.view);
|
||||
await expectWelcome(second.view, false);
|
||||
});
|
||||
|
||||
test("the first keystroke dismisses the welcome overlay early", async () => {
|
||||
const subject = fixture({
|
||||
frame: EMPTY_FRAME,
|
||||
showSplash: true,
|
||||
visible: true,
|
||||
});
|
||||
await ready(subject.view);
|
||||
await reveal(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
fireEvent.input(subject.view.getByLabelText("Terminal input"), {
|
||||
@@ -299,52 +438,6 @@ test("the first keystroke dismisses the welcome overlay", async () => {
|
||||
await expectWelcome(subject.view, false);
|
||||
});
|
||||
|
||||
test("non-empty output after the reveal dismisses the welcome overlay", async () => {
|
||||
const subject = fixture({ frame: EMPTY_FRAME });
|
||||
await ready(subject.view);
|
||||
await reveal(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
subject.rerender({ frame: VISIBLE_FRAME });
|
||||
await expectWelcome(subject.view, false);
|
||||
});
|
||||
|
||||
test("empty active output keeps the welcome overlay", async () => {
|
||||
const subject = fixture({ frame: EMPTY_FRAME });
|
||||
await ready(subject.view);
|
||||
await reveal(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
subject.rerender({
|
||||
frame: {
|
||||
...EMPTY_FRAME,
|
||||
viewport: { ...EMPTY_FRAME.viewport, generation: 2 },
|
||||
},
|
||||
});
|
||||
await expectWelcome(subject.view, true);
|
||||
});
|
||||
|
||||
test("non-empty output from an inactive PTY keeps the welcome overlay", async () => {
|
||||
const subject = fixture({
|
||||
sessionFrames: [{ frame: EMPTY_FRAME, sessionId: "one" }],
|
||||
sessions: [
|
||||
{ active: true, closing: false, id: "one", title: "SHELL" },
|
||||
{ active: false, closing: false, id: "two", title: "LOG" },
|
||||
],
|
||||
});
|
||||
await ready(subject.view);
|
||||
await reveal(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
subject.rerender({
|
||||
sessionFrames: [
|
||||
{ frame: EMPTY_FRAME, sessionId: "one" },
|
||||
{ frame: VISIBLE_FRAME, sessionId: "two" },
|
||||
],
|
||||
});
|
||||
await expectWelcome(subject.view, true);
|
||||
});
|
||||
|
||||
test("mounted wheel path accumulates fractional lines per active session", async () => {
|
||||
const { calls, view } = fixture();
|
||||
await ready(view);
|
||||
@@ -764,213 +857,3 @@ test("the handoff chord still toggles with the tab layer installed", async () =>
|
||||
// Splash animation lifecycle.
|
||||
//
|
||||
// This substrate is mounted unconditionally on every route and merely
|
||||
// CSS-concealed in Buzz mode, so "is the splash animating?" is a question about
|
||||
// `owner`, not about mounting. A loop gated only on `welcomeVisible` ran at
|
||||
// 120 rAF/s behind the whole app forever for anyone who never opened the
|
||||
// terminal; that is the defect these arms exist to keep dead.
|
||||
//
|
||||
// The rAF clock is driven by hand rather than by jsdom's visual loop: a real
|
||||
// clock can only show "frames happened", while a manual one can advance AFTER a
|
||||
// transition and prove no successor callback was scheduled. Distinguishing a
|
||||
// cancelled frame from a frame that was never scheduled needs that.
|
||||
function splashClock() {
|
||||
const real = {
|
||||
request: dom.window.requestAnimationFrame,
|
||||
cancel: dom.window.cancelAnimationFrame,
|
||||
};
|
||||
const pending = new Map();
|
||||
let nextHandle = 1;
|
||||
let scheduled = 0;
|
||||
let cancelled = 0;
|
||||
dom.window.requestAnimationFrame = (callback) => {
|
||||
const handle = nextHandle++;
|
||||
pending.set(handle, callback);
|
||||
scheduled += 1;
|
||||
return handle;
|
||||
};
|
||||
dom.window.cancelAnimationFrame = (handle) => {
|
||||
if (pending.delete(handle)) cancelled += 1;
|
||||
};
|
||||
return {
|
||||
get scheduled() {
|
||||
return scheduled;
|
||||
},
|
||||
get cancelled() {
|
||||
return cancelled;
|
||||
},
|
||||
get outstanding() {
|
||||
return pending.size;
|
||||
},
|
||||
/** Run every queued callback once, as one frame would. */
|
||||
advance(now = 16) {
|
||||
const due = [...pending.entries()];
|
||||
pending.clear();
|
||||
act(() => {
|
||||
for (const [, callback] of due) callback(now);
|
||||
});
|
||||
return due.length;
|
||||
},
|
||||
restore() {
|
||||
dom.window.requestAnimationFrame = real.request;
|
||||
dom.window.cancelAnimationFrame = real.cancel;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Draws issued to the banner/splash canvas only. */
|
||||
function bannerDraws(view) {
|
||||
const banner = view.container.querySelector(".buzz-terminal-welcome");
|
||||
if (!banner) return [];
|
||||
return paintLog.filter((entry) => entry.canvas === banner);
|
||||
}
|
||||
|
||||
test("the splash animation runs only while the terminal is revealed", async () => {
|
||||
const clock = splashClock();
|
||||
try {
|
||||
// ARM 1 — concealed entry. `enabled` defaults true, so this is the
|
||||
// production-shaped state that used to animate behind the channel view.
|
||||
const subject = fixture();
|
||||
await ready(subject.view);
|
||||
const substrate = subject.view.container.querySelector(
|
||||
".buzz-terminal-substrate",
|
||||
);
|
||||
assert.equal(substrate.dataset.terminalOwner, "buzz");
|
||||
await expectWelcome(subject.view, true);
|
||||
assert.equal(
|
||||
clock.scheduled,
|
||||
0,
|
||||
"ARM 1: no splash frame is scheduled while Buzz owns the screen",
|
||||
);
|
||||
clock.advance();
|
||||
assert.equal(
|
||||
bannerDraws(subject.view).length,
|
||||
0,
|
||||
"ARM 1: and no hidden banner paint happens either",
|
||||
);
|
||||
|
||||
// ARM 2 — revealed. The positive control: the loop must actually run, or
|
||||
// arms 1/3 are satisfied by a loop that is simply broken everywhere.
|
||||
await reveal(subject.view);
|
||||
assert.ok(
|
||||
clock.scheduled > 0,
|
||||
"ARM 2: revealing the terminal starts the splash loop",
|
||||
);
|
||||
const afterReveal = clock.scheduled;
|
||||
assert.equal(clock.advance(), 1, "ARM 2: exactly one frame was pending");
|
||||
assert.ok(
|
||||
bannerDraws(subject.view).length > 0,
|
||||
"ARM 2: the revealed splash actually paints",
|
||||
);
|
||||
assert.ok(
|
||||
clock.scheduled > afterReveal,
|
||||
"ARM 2: the loop reschedules itself while revealed",
|
||||
);
|
||||
|
||||
// ARM 3 — terminal -> buzz. The regression users hit: leaving the terminal
|
||||
// must CANCEL the outstanding frame, not merely stop new ones. Advancing
|
||||
// the clock afterwards is what separates those two.
|
||||
const beforeConceal = clock.scheduled;
|
||||
const cancelledBefore = clock.cancelled;
|
||||
toggleChord();
|
||||
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
|
||||
assert.ok(
|
||||
clock.cancelled > cancelledBefore,
|
||||
"ARM 3: concealing cancels the frame that was already scheduled",
|
||||
);
|
||||
assert.equal(
|
||||
clock.outstanding,
|
||||
0,
|
||||
"ARM 3: nothing is left queued after cleanup",
|
||||
);
|
||||
const drawsBefore = bannerDraws(subject.view).length;
|
||||
clock.advance();
|
||||
clock.advance();
|
||||
assert.equal(
|
||||
clock.scheduled,
|
||||
beforeConceal,
|
||||
"ARM 3: no successor callback is scheduled after concealing",
|
||||
);
|
||||
assert.equal(
|
||||
bannerDraws(subject.view).length,
|
||||
drawsBefore,
|
||||
"ARM 3: and no further hidden banner paint occurs",
|
||||
);
|
||||
|
||||
// ARM 4 — buzz -> terminal again. Proves cleanup did not poison the
|
||||
// positive path: a fix that permanently kills the loop passes 1 and 3.
|
||||
await reveal(subject.view);
|
||||
assert.ok(
|
||||
clock.scheduled > beforeConceal,
|
||||
"ARM 4: re-revealing restarts the splash loop",
|
||||
);
|
||||
clock.advance();
|
||||
assert.ok(
|
||||
bannerDraws(subject.view).length > drawsBefore,
|
||||
"ARM 4: and it paints again",
|
||||
);
|
||||
} finally {
|
||||
clock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// The gate above reads `owner` alone, which is only sound because
|
||||
// `owner === "terminal"` implies `enabled`: the sole `commitOwner("terminal")`
|
||||
// call site sits behind an `!enabled` early return, and dropping `enabled`
|
||||
// forces ownership back to Buzz. That implication is true by construction
|
||||
// today and nothing else in this file pins it, so a refactor adding a second
|
||||
// reveal path outside the `enabled` guard would silently widen the gate. This
|
||||
// arm is that implication, held as a regression test in both directions.
|
||||
test("a disabled terminal cannot reveal, so owner-gating cannot widen", async () => {
|
||||
const clock = splashClock();
|
||||
try {
|
||||
const subject = fixture({ enabled: false });
|
||||
await ready(subject.view);
|
||||
const substrate = subject.view.container.querySelector(
|
||||
".buzz-terminal-substrate",
|
||||
);
|
||||
|
||||
toggleChord();
|
||||
await waitFor(() => assert.ok(substrate.dataset.terminalOwner));
|
||||
assert.equal(
|
||||
substrate.dataset.terminalOwner,
|
||||
"buzz",
|
||||
"the toggle chord must not reveal a terminal that has no session",
|
||||
);
|
||||
assert.equal(
|
||||
clock.scheduled,
|
||||
0,
|
||||
"and no splash frame is scheduled while disabled",
|
||||
);
|
||||
clock.advance();
|
||||
assert.equal(
|
||||
bannerDraws(subject.view).length,
|
||||
0,
|
||||
"nor any hidden banner paint",
|
||||
);
|
||||
|
||||
// The other direction: losing `enabled` while revealed must concede
|
||||
// ownership and cancel the loop, which is what makes the `enabled` term
|
||||
// redundant in the animation gate rather than merely absent from it.
|
||||
subject.rerender({ enabled: true });
|
||||
await reveal(subject.view);
|
||||
assert.ok(clock.scheduled > 0, "the enabled terminal does reveal and run");
|
||||
const afterReveal = clock.scheduled;
|
||||
|
||||
subject.rerender({ enabled: false });
|
||||
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
|
||||
assert.equal(
|
||||
clock.outstanding,
|
||||
0,
|
||||
"losing the session cancels the outstanding splash frame",
|
||||
);
|
||||
clock.advance();
|
||||
clock.advance();
|
||||
assert.equal(
|
||||
clock.scheduled,
|
||||
afterReveal,
|
||||
"and schedules no successor once disabled",
|
||||
);
|
||||
} finally {
|
||||
clock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { ChevronRight, Maximize2, Minimize2, Plus, X } from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { FadeController } from "./fadeController";
|
||||
import {
|
||||
INITIAL_HANDOFF_STATE,
|
||||
accumulateScrollLines,
|
||||
@@ -37,7 +37,6 @@ export type TerminalSessionTab = {
|
||||
};
|
||||
|
||||
type TerminalSubstrateProps = {
|
||||
appSurfaceRef?: React.RefObject<HTMLDivElement | null>;
|
||||
channelName: string | null;
|
||||
frame?: TerminalFrame;
|
||||
sessionFrames?: readonly { sessionId: string; frame: TerminalFrame }[];
|
||||
@@ -45,8 +44,16 @@ type TerminalSubstrateProps = {
|
||||
bracketedPaste: boolean;
|
||||
focusReportingEnabled: boolean;
|
||||
enabled?: boolean;
|
||||
mode?: "docked" | "maximized";
|
||||
visible?: boolean;
|
||||
onHide?: () => void;
|
||||
onModeChange?: (mode: "docked" | "maximized") => void;
|
||||
onToggle?: () => void;
|
||||
onFrameConsumed?: (frame: TerminalFrame) => void;
|
||||
onViewportSize?: (size: TerminalViewportSize) => void;
|
||||
viewportReportingEnabled?: boolean;
|
||||
showSplash?: boolean;
|
||||
onSplashStarted?: () => void;
|
||||
onInput: (text: string) => void;
|
||||
/** Whole cells scrolled, keeping the DOM's sign: negative goes back. */
|
||||
onScroll: (lines: number) => void;
|
||||
@@ -66,26 +73,26 @@ function isToggleChord(event: KeyboardEvent): boolean {
|
||||
}
|
||||
|
||||
const { width: CELL_WIDTH, height: CELL_HEIGHT } = TERMINAL_CELL_METRICS;
|
||||
|
||||
function hasVisibleOutput(frame: TerminalFrame): boolean {
|
||||
return frame.rows.some((row) =>
|
||||
row.spans.some((span) =>
|
||||
span.clusters.some((cluster) => cluster.text.trim().length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
const NOOP = () => {};
|
||||
const SPLASH_DURATION_MS = 2_500;
|
||||
|
||||
export function TerminalSubstrate({
|
||||
appSurfaceRef,
|
||||
channelName,
|
||||
frame,
|
||||
sessionFrames,
|
||||
sessions,
|
||||
bracketedPaste,
|
||||
focusReportingEnabled,
|
||||
enabled = true,
|
||||
mode = "docked",
|
||||
visible = true,
|
||||
onHide = NOOP,
|
||||
onModeChange = NOOP,
|
||||
onToggle,
|
||||
onFrameConsumed,
|
||||
onViewportSize,
|
||||
viewportReportingEnabled = true,
|
||||
showSplash = true,
|
||||
onSplashStarted,
|
||||
onInput,
|
||||
onScroll,
|
||||
onTerminalFocusChange,
|
||||
@@ -97,17 +104,20 @@ export function TerminalSubstrate({
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const bannerCanvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const fadeRef = React.useRef<FadeController | null>(null);
|
||||
const handoffRef = React.useRef(INITIAL_HANDOFF_STATE);
|
||||
const gridsRef = React.useRef(new Map<string, TerminalGrid>());
|
||||
const appliedFramesRef = React.useRef(new WeakSet<TerminalFrame>());
|
||||
const gridRef = React.useRef<TerminalGrid | null>(null);
|
||||
const paintedPaletteRef = React.useRef(terminalPalette);
|
||||
const paintedSessionRef = React.useRef<string | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
const reportedFocusRef = React.useRef<boolean | null>(null);
|
||||
const reportedViewportSizeRef = React.useRef<TerminalViewportSize | null>(
|
||||
null,
|
||||
);
|
||||
const dragCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const resizeReportFrameRef = React.useRef(0);
|
||||
const resizingRef = React.useRef(false);
|
||||
const scrollBySessionRef = React.useRef(new Map<string, number>());
|
||||
const revealedRef = React.useRef(false);
|
||||
const activeSession = sessions.find((session) => session.active);
|
||||
const activeSessionId = activeSession?.id ?? null;
|
||||
const frames = React.useMemo(
|
||||
@@ -118,21 +128,19 @@ export function TerminalSubstrate({
|
||||
);
|
||||
const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz");
|
||||
const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 });
|
||||
const [welcomeVisible, setWelcomeVisible] = React.useState(true);
|
||||
const [welcomeVisible, setWelcomeVisible] = React.useState(false);
|
||||
const [cursorPainted, setCursorPainted] = React.useState(true);
|
||||
const [cursorReset, setCursorReset] = React.useState(0);
|
||||
const [reducedMotion, setReducedMotion] = React.useState(
|
||||
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
? "⌘J"
|
||||
: "CTRL+J";
|
||||
const getAppSurface = React.useCallback(
|
||||
() =>
|
||||
appSurfaceRef?.current ??
|
||||
document.querySelector<HTMLDivElement>(".buzz-huddle-app-surface"),
|
||||
[appSurfaceRef],
|
||||
);
|
||||
const [dockHeight, setDockHeight] = React.useState(() => {
|
||||
const stored = Number.parseInt(
|
||||
window.localStorage.getItem("buzz-terminal-dock-height") ?? "",
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(stored) ? stored : 320;
|
||||
});
|
||||
const banner = React.useMemo(
|
||||
() =>
|
||||
buildTerminalBanner(
|
||||
@@ -149,32 +157,9 @@ export function TerminalSubstrate({
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const commitOwner = React.useEffectEvent((next: "buzz" | "terminal") => {
|
||||
const appSurface = getAppSurface();
|
||||
if (!appSurface) return;
|
||||
if (next === "terminal") {
|
||||
revealedRef.current = true;
|
||||
previousFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
appSurface.inert = true;
|
||||
appSurface.setAttribute("aria-hidden", "true");
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
} else {
|
||||
appSurface.inert = false;
|
||||
appSurface.removeAttribute("aria-hidden");
|
||||
const previous = previousFocusRef.current;
|
||||
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
||||
else appSurface.focus({ preventScroll: true });
|
||||
}
|
||||
setOwner(next);
|
||||
});
|
||||
|
||||
const forceBuzzFallback = React.useEffectEvent(() => {
|
||||
handoffRef.current = { ...INITIAL_HANDOFF_STATE };
|
||||
commitOwner("buzz");
|
||||
fadeRef.current?.settle("conceal");
|
||||
setOwner("buzz");
|
||||
});
|
||||
|
||||
const sendInput = React.useEffectEvent((text: string) => {
|
||||
@@ -187,6 +172,12 @@ export function TerminalSubstrate({
|
||||
const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => {
|
||||
onFrameConsumed?.(nextFrame);
|
||||
});
|
||||
const beginSplash = React.useEffectEvent(() => {
|
||||
if (!showSplash) return false;
|
||||
onSplashStarted?.();
|
||||
setWelcomeVisible(true);
|
||||
return true;
|
||||
});
|
||||
/**
|
||||
* Tab chords are handled at the window in capture phase, like the ⌘J
|
||||
* handoff, so they win over the focused textarea. Gated on terminal
|
||||
@@ -212,6 +203,7 @@ export function TerminalSubstrate({
|
||||
return true;
|
||||
});
|
||||
const reportViewportSize = React.useEffectEvent(() => {
|
||||
if (!viewportReportingEnabled) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
@@ -220,14 +212,33 @@ export function TerminalSubstrate({
|
||||
const pixelHeight = Math.max(1, Math.round(bounds.height * dpr));
|
||||
const columns = Math.max(1, Math.floor(bounds.width / CELL_WIDTH));
|
||||
const rows = Math.max(1, Math.floor(bounds.height / CELL_HEIGHT));
|
||||
const size = { columns, rows, pixelWidth, pixelHeight };
|
||||
if (resizingRef.current) return;
|
||||
setViewport((current) =>
|
||||
current.columns === columns && current.rows === rows
|
||||
? current
|
||||
: { columns, rows },
|
||||
);
|
||||
onViewportSize?.({ columns, rows, pixelWidth, pixelHeight });
|
||||
const reported = reportedViewportSizeRef.current;
|
||||
if (
|
||||
reported?.columns === columns &&
|
||||
reported.rows === rows &&
|
||||
reported.pixelWidth === pixelWidth &&
|
||||
reported.pixelHeight === pixelHeight
|
||||
)
|
||||
return;
|
||||
reportedViewportSizeRef.current = size;
|
||||
onViewportSize?.(size);
|
||||
});
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
dragCleanupRef.current?.();
|
||||
window.cancelAnimationFrame(resizeReportFrameRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) forceBuzzFallback();
|
||||
}, [enabled]);
|
||||
@@ -258,11 +269,15 @@ export function TerminalSubstrate({
|
||||
reportViewportSize();
|
||||
const ResizeObserverConstructor = window.ResizeObserver;
|
||||
if (!ResizeObserverConstructor) return;
|
||||
const observer = new ResizeObserverConstructor(reportViewportSize);
|
||||
const observer = new ResizeObserverConstructor(() => reportViewportSize());
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (viewportReportingEnabled) reportViewportSize();
|
||||
}, [viewportReportingEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focusReportingEnabled) {
|
||||
reportedFocusRef.current = null;
|
||||
@@ -284,92 +299,71 @@ export function TerminalSubstrate({
|
||||
}, [focusReportingEnabled, onTerminalFocusChange, owner]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const appSurface = getAppSurface();
|
||||
if (!appSurface) return;
|
||||
fadeRef.current = new FadeController(appSurface);
|
||||
if (!enabled) {
|
||||
forceBuzzFallback();
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
if (runTabChord(event)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
if (!isToggleChord(event)) return;
|
||||
if (event.isComposing) {
|
||||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||
type: "focus-lost",
|
||||
}).state;
|
||||
return;
|
||||
}
|
||||
if (!isToggleChord(event) || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const result = reduceHandoff(handoffRef.current, {
|
||||
type: "chord-down",
|
||||
repeat: event.repeat,
|
||||
});
|
||||
handoffRef.current = result.state;
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!enabled || !isToggleChord(event)) return;
|
||||
if (!isToggleChord(event) || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (event.isComposing) return;
|
||||
const result = reduceHandoff(handoffRef.current, { type: "chord-up" });
|
||||
handoffRef.current = result.state;
|
||||
if (!result.toggled) return;
|
||||
commitOwner(result.state.owner);
|
||||
fadeRef.current?.toggle(
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
};
|
||||
const cancelChord = () => {
|
||||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||||
type: "focus-lost",
|
||||
}).state;
|
||||
if (onToggle) onToggle();
|
||||
else {
|
||||
setOwner((current) => {
|
||||
const next = current === "terminal" ? "buzz" : "terminal";
|
||||
if (next === "terminal") {
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
window.addEventListener("blur", cancelChord);
|
||||
document.addEventListener("visibilitychange", cancelChord);
|
||||
if (onToggle) {
|
||||
setOwner("terminal");
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
return () => {
|
||||
fadeRef.current?.settle("conceal");
|
||||
fadeRef.current = null;
|
||||
appSurface.inert = false;
|
||||
appSurface.removeAttribute("aria-hidden");
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("blur", cancelChord);
|
||||
document.removeEventListener("visibilitychange", cancelChord);
|
||||
};
|
||||
}, [enabled, getAppSurface]);
|
||||
}, [enabled, onToggle]);
|
||||
|
||||
// The banner's animation loop. It runs only while the splash is ON SCREEN,
|
||||
// which needs BOTH conditions below — they are different questions:
|
||||
// - `welcomeVisible`: the splash has not been dismissed by terminal output.
|
||||
// - `owner === "terminal"`: the terminal layer is revealed at all.
|
||||
//
|
||||
// `owner` is the load-bearing one and it is not optional. This substrate is
|
||||
// mounted unconditionally by AppShell on every route and merely CSS-concealed
|
||||
// in Buzz mode (`.buzz-terminal-substrate` is `position:absolute; inset:0`),
|
||||
// and `welcomeVisible` starts `true` and only clears on terminal INPUT. So a
|
||||
// loop gated on `welcomeVisible` alone runs forever behind the whole app for
|
||||
// anyone who never opens the terminal — measured at 120 rAF/s in the channel
|
||||
// view, repainting a canvas nobody can see and slowing every other paint.
|
||||
//
|
||||
// Deliberately NOT gated on `enabled`: that is `available && Boolean(active)`
|
||||
// where `available` is `isTauri()`, and a session is auto-created on channel
|
||||
// open (TerminalBootstrap), so `enabled` is true while still concealed in the
|
||||
// app and permanently false in the browser — it would gate the tests green
|
||||
// and leave real users paying the cost. `owner` is user-gestured in both.
|
||||
//
|
||||
// `prefers-reduced-motion` takes the STATIC path (no motion argument), which
|
||||
// is the shipped painter call, not a paused animation. Those are different:
|
||||
// a stopped loop still parks on whatever phase it halted at.
|
||||
React.useEffect(() => {
|
||||
if (!visible) {
|
||||
setWelcomeVisible(false);
|
||||
return;
|
||||
}
|
||||
if (!viewportReportingEnabled || !banner || !beginSplash()) return;
|
||||
}, [banner, viewportReportingEnabled, visible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!welcomeVisible) return;
|
||||
const timeout = window.setTimeout(
|
||||
() => setWelcomeVisible(false),
|
||||
SPLASH_DURATION_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [welcomeVisible]);
|
||||
|
||||
// The splash is a bounded decoration, never a PTY-readiness gate. Each open
|
||||
// gets one animation epoch; input may dismiss it early and the deadline ends
|
||||
// it unconditionally even when an idle shell emits no new frames.
|
||||
React.useEffect(() => {
|
||||
const canvas = bannerCanvasRef.current;
|
||||
if (!canvas || !banner || !terminalPalette || !welcomeVisible) return;
|
||||
if (owner !== "terminal") return;
|
||||
|
||||
if (!canvas || !banner || !terminalPalette || !welcomeVisible || !visible)
|
||||
return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (reducedMotion) {
|
||||
if (!paintTerminalBanner(canvas, banner, terminalPalette, dpr))
|
||||
@@ -377,7 +371,6 @@ export function TerminalSubstrate({
|
||||
return;
|
||||
}
|
||||
|
||||
// Built once per palette, not per frame: the table is phase-independent.
|
||||
const table = buildBannerColorTable(terminalPalette);
|
||||
const start = performance.now();
|
||||
let frame = 0;
|
||||
@@ -395,47 +388,11 @@ export function TerminalSubstrate({
|
||||
};
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [banner, owner, reducedMotion, terminalPalette, welcomeVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
for (const delivered of frames) {
|
||||
if (appliedFramesRef.current.has(delivered.frame)) continue;
|
||||
appliedFramesRef.current.add(delivered.frame);
|
||||
let grid = gridsRef.current.get(delivered.sessionId);
|
||||
if (!grid) {
|
||||
grid = new TerminalGrid(delivered.frame.viewport);
|
||||
gridsRef.current.set(delivered.sessionId, grid);
|
||||
} else if (
|
||||
grid.viewport.generation !== delivered.frame.viewport.generation ||
|
||||
grid.viewport.columns !== delivered.frame.viewport.columns ||
|
||||
grid.viewport.screenLines !== delivered.frame.viewport.screenLines
|
||||
) {
|
||||
grid.resize(delivered.frame.viewport);
|
||||
}
|
||||
grid.apply(delivered.frame);
|
||||
consumeFrame(delivered.frame);
|
||||
if (
|
||||
delivered.sessionId === activeSessionId &&
|
||||
revealedRef.current &&
|
||||
hasVisibleOutput(delivered.frame)
|
||||
) {
|
||||
// Policy: the banner is a splash for the reveal, so spawn-time shell
|
||||
// output must not dismiss it. Only visible output from the active PTY
|
||||
// that arrives after the terminal has been revealed (or the first
|
||||
// keystroke, see sendInput) removes the overlay.
|
||||
setWelcomeVisible(false);
|
||||
}
|
||||
}
|
||||
gridRef.current = activeSessionId
|
||||
? (gridsRef.current.get(activeSessionId) ?? null)
|
||||
: null;
|
||||
}, [banner, reducedMotion, terminalPalette, visible, welcomeVisible]);
|
||||
|
||||
const paintTerminal = React.useEffectEvent(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (!terminalPalette) {
|
||||
forceBuzzFallback();
|
||||
return;
|
||||
}
|
||||
if (!canvas || !terminalPalette) return;
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
if (!context) {
|
||||
forceBuzzFallback();
|
||||
@@ -473,6 +430,34 @@ export function TerminalSubstrate({
|
||||
}
|
||||
gridRef.current?.setCursorPainted(cursorPainted);
|
||||
gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette);
|
||||
});
|
||||
|
||||
// Palette and blink changes must trigger a repaint; paintTerminal is an
|
||||
// Effect Event, so the dependency analyzer cannot see those reads.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: visual-only inputs intentionally trigger this paint effect.
|
||||
React.useEffect(() => {
|
||||
for (const delivered of frames) {
|
||||
if (appliedFramesRef.current.has(delivered.frame)) continue;
|
||||
appliedFramesRef.current.add(delivered.frame);
|
||||
let grid = gridsRef.current.get(delivered.sessionId);
|
||||
if (!grid) {
|
||||
grid = new TerminalGrid(delivered.frame.viewport);
|
||||
gridsRef.current.set(delivered.sessionId, grid);
|
||||
} else if (
|
||||
grid.viewport.generation !== delivered.frame.viewport.generation ||
|
||||
grid.viewport.columns !== delivered.frame.viewport.columns ||
|
||||
grid.viewport.screenLines !== delivered.frame.viewport.screenLines
|
||||
) {
|
||||
grid.resize(delivered.frame.viewport);
|
||||
}
|
||||
grid.apply(delivered.frame);
|
||||
consumeFrame(delivered.frame);
|
||||
}
|
||||
gridRef.current = activeSessionId
|
||||
? (gridsRef.current.get(activeSessionId) ?? null)
|
||||
: null;
|
||||
|
||||
paintTerminal();
|
||||
}, [activeSessionId, cursorPainted, frames, terminalPalette]);
|
||||
|
||||
const runTabAction = (action: () => void) => {
|
||||
@@ -486,8 +471,13 @@ export function TerminalSubstrate({
|
||||
<section
|
||||
aria-label="Buzz Term"
|
||||
className="buzz-terminal-substrate"
|
||||
data-terminal-mode={mode}
|
||||
data-terminal-owner={owner}
|
||||
style={terminalStyle}
|
||||
data-terminal-visible={visible ? "true" : "false"}
|
||||
style={{
|
||||
...terminalStyle,
|
||||
...(mode === "docked" ? { height: dockHeight } : undefined),
|
||||
}}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
const sessionId = activeSession?.id;
|
||||
@@ -507,6 +497,103 @@ export function TerminalSubstrate({
|
||||
if (result.lines !== 0) onScroll(result.lines);
|
||||
}}
|
||||
>
|
||||
{mode === "docked" ? (
|
||||
<hr
|
||||
aria-label="Resize Buzz Term"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemax={Math.round(window.innerHeight * 0.7)}
|
||||
aria-valuemin={180}
|
||||
aria-valuenow={Math.round(dockHeight)}
|
||||
className="buzz-terminal-resize-handle"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowUp" ? 16 : -16;
|
||||
const next = Math.max(
|
||||
180,
|
||||
Math.min(window.innerHeight * 0.7, dockHeight + delta),
|
||||
);
|
||||
setDockHeight(next);
|
||||
window.localStorage.setItem(
|
||||
"buzz-terminal-dock-height",
|
||||
String(Math.round(next)),
|
||||
);
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
dragCleanupRef.current?.();
|
||||
window.cancelAnimationFrame(resizeReportFrameRef.current);
|
||||
const handle = event.currentTarget;
|
||||
const substrate = handle.closest<HTMLElement>(
|
||||
".buzz-terminal-substrate",
|
||||
);
|
||||
if (!substrate) return;
|
||||
const pointerId = event.pointerId;
|
||||
handle.setPointerCapture(pointerId);
|
||||
resizingRef.current = true;
|
||||
substrate.dataset.terminalResizing = "true";
|
||||
const startY = event.clientY;
|
||||
const startHeight = dockHeight;
|
||||
let nextHeight = startHeight;
|
||||
let frame = 0;
|
||||
const applyHeight = () => {
|
||||
frame = 0;
|
||||
substrate.style.height = `${nextHeight}px`;
|
||||
// Repaint the canvas at its new CSS size in the same visual
|
||||
// frame. PTY geometry is still reported only on release, but
|
||||
// leaving the old backing bitmap in a `height: 100%` canvas
|
||||
// makes the browser stretch terminal rows during the drag.
|
||||
paintTerminal();
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
handle.removeEventListener("pointermove", move);
|
||||
handle.removeEventListener("pointerup", finish);
|
||||
handle.removeEventListener("pointercancel", finish);
|
||||
if (dragCleanupRef.current === cleanup)
|
||||
dragCleanupRef.current = null;
|
||||
};
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) return;
|
||||
nextHeight = Math.max(
|
||||
180,
|
||||
Math.min(
|
||||
window.innerHeight * 0.7,
|
||||
startHeight + startY - moveEvent.clientY,
|
||||
),
|
||||
);
|
||||
if (!frame) frame = window.requestAnimationFrame(applyHeight);
|
||||
};
|
||||
const finish = (finishEvent: PointerEvent) => {
|
||||
if (finishEvent.pointerId !== pointerId) return;
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
applyHeight();
|
||||
}
|
||||
cleanup();
|
||||
resizingRef.current = false;
|
||||
delete substrate.dataset.terminalResizing;
|
||||
setDockHeight(nextHeight);
|
||||
window.localStorage.setItem(
|
||||
"buzz-terminal-dock-height",
|
||||
String(Math.round(nextHeight)),
|
||||
);
|
||||
resizeReportFrameRef.current = window.requestAnimationFrame(
|
||||
() => {
|
||||
resizeReportFrameRef.current = 0;
|
||||
if (!resizingRef.current) reportViewportSize();
|
||||
},
|
||||
);
|
||||
};
|
||||
dragCleanupRef.current = cleanup;
|
||||
handle.addEventListener("pointermove", move);
|
||||
handle.addEventListener("pointerup", finish);
|
||||
handle.addEventListener("pointercancel", finish);
|
||||
}}
|
||||
tabIndex={0}
|
||||
/>
|
||||
) : null}
|
||||
<div className="buzz-terminal-contract-bar">
|
||||
<div className="buzz-terminal-tabs" role="tablist">
|
||||
{sessions.map((session, index) => (
|
||||
@@ -519,6 +606,16 @@ export function TerminalSubstrate({
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
aria-label={`Close ${session.title}`}
|
||||
className="buzz-terminal-close"
|
||||
disabled={session.closing}
|
||||
onClick={() => runTabAction(() => onCloseSession(session.id))}
|
||||
type="button"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Terminal ${index + 1}${session.closing ? ", closing" : session.title !== "SHELL" ? `, ${session.title}` : ""}`}
|
||||
aria-selected={session.active}
|
||||
className="buzz-terminal-tab-select"
|
||||
disabled={session.closing}
|
||||
@@ -526,19 +623,19 @@ export function TerminalSubstrate({
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="buzz-terminal-designator">
|
||||
SYS.{String(index + 1).padStart(2, "0")}
|
||||
<span className="buzz-terminal-designator buzz-terminal-tab-title">
|
||||
{session.title === "SHELL" ? (
|
||||
<>
|
||||
<ChevronRight />
|
||||
<span>{index + 1}</span>
|
||||
</>
|
||||
) : (
|
||||
session.title
|
||||
)}
|
||||
</span>
|
||||
<span>{session.closing ? "CLOSING" : session.title}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Close ${session.title}`}
|
||||
className="buzz-terminal-close"
|
||||
disabled={session.closing}
|
||||
onClick={() => runTabAction(() => onCloseSession(session.id))}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
{session.closing ? (
|
||||
<span className="buzz-terminal-tab-title">Closing…</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -548,20 +645,36 @@ export function TerminalSubstrate({
|
||||
onClick={() => runTabAction(onNewSession)}
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
<Plus />
|
||||
</button>
|
||||
</div>
|
||||
<div className="buzz-terminal-readout">
|
||||
<span>{channelName ? `#${channelName}` : "BUZZ"}</span>
|
||||
<span>LOCAL PTY · PRIVATE</span>
|
||||
<span>{shortcutLabel} BUZZ</span>
|
||||
<button
|
||||
aria-label={
|
||||
mode === "maximized" ? "Restore Buzz Term" : "Maximize Buzz Term"
|
||||
}
|
||||
className="buzz-terminal-window-action"
|
||||
onClick={() =>
|
||||
onModeChange(mode === "maximized" ? "docked" : "maximized")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{mode === "maximized" ? <Minimize2 /> : <Maximize2 />}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Hide Buzz Term"
|
||||
className="buzz-terminal-window-action"
|
||||
onClick={onHide}
|
||||
type="button"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
|
||||
<div
|
||||
className="buzz-terminal-viewport"
|
||||
className="buzz-terminal-viewport px-5 pt-2"
|
||||
onMouseDown={(event) => {
|
||||
if (owner !== "terminal") return;
|
||||
// Preventing the canvas mousedown also suppresses selection. Revisit
|
||||
// this when the terminal gains mouse selection support.
|
||||
event.preventDefault();
|
||||
@@ -616,7 +729,7 @@ export function TerminalSubstrate({
|
||||
}}
|
||||
ref={textareaRef}
|
||||
spellCheck={false}
|
||||
tabIndex={owner === "terminal" ? 0 : -1}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
|
||||
import {
|
||||
resetTerminalPanelForTests,
|
||||
setTerminalPanelMode,
|
||||
setTerminalSessionChannels,
|
||||
toggleTerminalPanel,
|
||||
getTerminalPanelSnapshotForTests,
|
||||
} from "./terminalPanelStore.ts";
|
||||
|
||||
beforeEach(resetTerminalPanelForTests);
|
||||
|
||||
test("panel toggles between closed and the docked default", () => {
|
||||
toggleTerminalPanel();
|
||||
assert.equal(getTerminalPanelSnapshotForTests().mode, "docked");
|
||||
toggleTerminalPanel();
|
||||
assert.equal(getTerminalPanelSnapshotForTests().mode, "closed");
|
||||
setTerminalPanelMode("maximized");
|
||||
toggleTerminalPanel();
|
||||
assert.equal(getTerminalPanelSnapshotForTests().mode, "closed");
|
||||
});
|
||||
|
||||
test("session channel identities are de-duplicated", () => {
|
||||
setTerminalSessionChannels(["one", "one", "two"]);
|
||||
// Regression guard: accepting an iterable (rather than Session objects) keeps
|
||||
// this store UI-only and prevents mutable PTYs from leaking into header state.
|
||||
setTerminalSessionChannels(new Set(["one", "two"]));
|
||||
assert.deepEqual(
|
||||
[...getTerminalPanelSnapshotForTests().sessionChannelIds],
|
||||
["one", "two"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type TerminalPanelMode = "closed" | "docked" | "maximized";
|
||||
|
||||
type Snapshot = {
|
||||
mode: TerminalPanelMode;
|
||||
sessionChannelIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
let snapshot: Snapshot = { mode: "closed", sessionChannelIds: new Set() };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(next: Snapshot) {
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setTerminalPanelMode(mode: TerminalPanelMode) {
|
||||
if (snapshot.mode === mode) return;
|
||||
publish({ ...snapshot, mode });
|
||||
}
|
||||
|
||||
export function toggleTerminalPanel() {
|
||||
setTerminalPanelMode(snapshot.mode === "closed" ? "docked" : "closed");
|
||||
}
|
||||
|
||||
export function setTerminalSessionChannels(channelIds: Iterable<string>) {
|
||||
const next = new Set(channelIds);
|
||||
if (
|
||||
next.size === snapshot.sessionChannelIds.size &&
|
||||
[...next].every((id) => snapshot.sessionChannelIds.has(id))
|
||||
)
|
||||
return;
|
||||
publish({ ...snapshot, sessionChannelIds: next });
|
||||
}
|
||||
|
||||
export function useTerminalPanel() {
|
||||
return React.useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
() => snapshot,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetTerminalPanelForTests() {
|
||||
snapshot = { mode: "closed", sessionChannelIds: new Set() };
|
||||
}
|
||||
|
||||
export function getTerminalPanelSnapshotForTests() {
|
||||
return snapshot;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
@import "./globals/skeleton.css";
|
||||
@import "./globals/spoilers.css";
|
||||
@import "./globals/components.css";
|
||||
@import "./globals/terminal.css";
|
||||
@import "./globals/utilities.css";
|
||||
@import "./globals/media-controls.css";
|
||||
@import "./globals/avatar-framing.css";
|
||||
|
||||
@@ -751,119 +751,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.buzz-terminal-substrate {
|
||||
background: var(--buzz-terminal-background, #101014);
|
||||
color: var(--buzz-terminal-foreground, #e8e8ec);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-variant-ligatures: none;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-contract-bar {
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
flex: 0 0 32px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs,
|
||||
.buzz-terminal-readout {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab,
|
||||
.buzz-terminal-tab-select,
|
||||
.buzz-terminal-close,
|
||||
.buzz-terminal-new-tab {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
font: inherit;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
opacity: 0.62;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active::after {
|
||||
background: hsl(var(--buzz-selected-accent));
|
||||
bottom: 0;
|
||||
content: "";
|
||||
height: 1px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-designator,
|
||||
.buzz-terminal-readout {
|
||||
font-size: 0.5625rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.buzz-terminal-close {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab:hover .buzz-terminal-close,
|
||||
.buzz-terminal-close:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-readout {
|
||||
gap: 14px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport,
|
||||
.buzz-terminal-viewport canvas {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buzz-terminal-welcome {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-input {
|
||||
height: 1px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
@layer components {
|
||||
.buzz-terminal-substrate {
|
||||
background: var(--buzz-terminal-background, #101014);
|
||||
color: var(--buzz-terminal-foreground, #e8e8ec);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-variant-ligatures: none;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-contract-bar {
|
||||
align-items: center;
|
||||
background: hsl(var(--secondary));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--secondary-foreground));
|
||||
display: flex;
|
||||
flex: 0 0 40px;
|
||||
font-family: inherit;
|
||||
font-variant-ligatures: normal;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
padding: 4px 1.25rem;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs,
|
||||
.buzz-terminal-readout {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab,
|
||||
.buzz-terminal-tab-select,
|
||||
.buzz-terminal-close,
|
||||
.buzz-terminal-new-tab {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: flex;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs {
|
||||
flex: 1 1 auto;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.buzz-terminal-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buzz-terminal-readout {
|
||||
background: hsl(var(--secondary));
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab {
|
||||
background: hsl(var(--background));
|
||||
border-radius: 4px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.buzz-terminal-tab:hover,
|
||||
.buzz-terminal-tab:focus-within {
|
||||
background: hsl(var(--foreground) / 0.06);
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-select {
|
||||
border-radius: inherit;
|
||||
max-width: 12rem;
|
||||
min-width: 0;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-active .buzz-terminal-tab-select {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.buzz-terminal-new-tab:hover {
|
||||
background: hsl(var(--foreground) / 0.06);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.buzz-terminal-new-tab {
|
||||
align-items: center;
|
||||
background: hsl(var(--background));
|
||||
border-radius: 4px;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
padding: 7px;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.buzz-terminal-new-tab svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buzz-terminal-designator {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
font-size: 0.75rem;
|
||||
gap: 2px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-designator svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.buzz-terminal-close {
|
||||
height: 16px;
|
||||
left: 8px;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-tab:hover .buzz-terminal-close,
|
||||
.buzz-terminal-tab:focus-within .buzz-terminal-close {
|
||||
color: hsl(var(--foreground));
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.buzz-terminal-close svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.buzz-terminal-readout {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport,
|
||||
.buzz-terminal-viewport canvas {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buzz-terminal-viewport canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buzz-terminal-welcome {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.buzz-terminal-input {
|
||||
height: 1px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.buzz-terminal-dock-host:has(.buzz-terminal-substrate) {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
flex-grow 180ms ease,
|
||||
flex-basis 180ms ease;
|
||||
}
|
||||
|
||||
.buzz-content-primary {
|
||||
transition:
|
||||
flex-grow 180ms ease,
|
||||
flex-basis 180ms ease;
|
||||
}
|
||||
|
||||
.buzz-terminal-dock-host:has([data-terminal-mode="maximized"]) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.buzz-content-primary:has(
|
||||
+ .buzz-terminal-dock-host [data-terminal-mode="maximized"]
|
||||
) {
|
||||
flex: 0 1 0%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.buzz-terminal-substrate {
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
inset: auto;
|
||||
min-height: 180px;
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
height 180ms ease,
|
||||
transform 180ms ease;
|
||||
width: 100%;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.buzz-terminal-substrate[data-terminal-resizing="true"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.buzz-terminal-substrate[data-terminal-visible="false"] {
|
||||
height: 0 !important;
|
||||
min-height: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.buzz-terminal-dock-host [data-terminal-mode="maximized"] {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.buzz-terminal-resize-handle {
|
||||
cursor: ns-resize;
|
||||
height: 5px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -3px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.buzz-terminal-window-action {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.buzz-terminal-window-action:hover,
|
||||
.buzz-terminal-window-action:focus-visible {
|
||||
background: hsl(var(--foreground) / 0.06);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.buzz-terminal-window-action svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-content-primary,
|
||||
.buzz-terminal-dock-host:has(.buzz-terminal-substrate),
|
||||
.buzz-terminal-substrate {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,16 +156,17 @@ async function reveal(page: Page) {
|
||||
"data-terminal-owner",
|
||||
"terminal",
|
||||
);
|
||||
// data-terminal-owner flips synchronously at chord-up; the 360ms reveal fade
|
||||
// is still running. Every capture below must wait for it to settle or it
|
||||
// photographs a half-faded app surface and reads as a rendering defect.
|
||||
// The dock opens in the normal app surface now; unlike the removed
|
||||
// full-screen takeover, it must not fade that surface away. Wait for the
|
||||
// dock's own height transition before interacting with its viewport.
|
||||
await expect(page.locator(TERM)).toHaveAttribute(
|
||||
"data-terminal-mode",
|
||||
"docked",
|
||||
);
|
||||
await expect(page.locator(TERM)).toBeVisible();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page
|
||||
.locator(".buzz-huddle-app-surface")
|
||||
.evaluate((el) => getComputedStyle(el).opacity),
|
||||
)
|
||||
.toBe("0");
|
||||
.poll(async () => page.locator(TERM).evaluate((el) => el.clientHeight))
|
||||
.toBeGreaterThanOrEqual(180);
|
||||
}
|
||||
|
||||
test("scrollback: wheel over Buzz Term reaches terminal_scroll", async ({
|
||||
@@ -217,16 +218,16 @@ test("concealed terminal viewport does not steal Buzz focus", async ({
|
||||
}) => {
|
||||
await reveal(page);
|
||||
await page.keyboard.press("Meta+j");
|
||||
await expect(page.locator(TERM)).toHaveAttribute(
|
||||
"data-terminal-owner",
|
||||
"buzz",
|
||||
);
|
||||
await expect(page.locator(TERM)).toHaveCount(0);
|
||||
|
||||
const input = page.getByLabel("Terminal input");
|
||||
await expect(input).not.toBeFocused();
|
||||
await page.locator(".buzz-terminal-viewport").click({
|
||||
force: true,
|
||||
position: { x: 40, y: 40 },
|
||||
});
|
||||
await expect(input).not.toBeFocused();
|
||||
await expect(input).toHaveCount(0);
|
||||
await page.getByTestId("chat-title").click();
|
||||
await page.keyboard.type("BUZZ_KEYSTROKE");
|
||||
const terminalInputs = await page.evaluate(
|
||||
() =>
|
||||
(window as typeof window & { __SAMI_TERM__: { inputs: string[] } })
|
||||
.__SAMI_TERM__.inputs,
|
||||
);
|
||||
expect(terminalInputs.join("")).not.toContain("BUZZ_KEYSTROKE");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user