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:
Wes
2026-08-04 11:10:47 -07:00
committed by GitHub
co-authored by Carl kenny lopez
parent bc9e6528a7
commit cb4a73e17d
15 changed files with 1256 additions and 616 deletions
-8
View File
@@ -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",
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -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>
+8 -1
View File
@@ -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;
}
+1
View File
@@ -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;
}
}
}
+20 -19
View File
@@ -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");
});