diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 736dad1f6a..8370e8efd4 100644 --- a/desktop/src/app/AppHuddleShell.tsx +++ b/desktop/src/app/AppHuddleShell.tsx @@ -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 ( - {isRoom ? null : terminal}
} > {hasCommunityRail && !isHuddleRoom ? ( } > diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx index 4ab2ea1df3..37be3448ee 100644 --- a/desktop/src/app/AppShellChannelSurface.tsx +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -11,6 +11,7 @@ type AppShellChannelSurfaceProps = { isHuddleRoom: boolean; isHuddleRoomStarting: boolean; mainInsetRef: React.RefObject; + terminal?: React.ReactNode; }; export function AppShellChannelSurface({ @@ -18,6 +19,7 @@ export function AppShellChannelSurface({ isHuddleRoom, isHuddleRoomStarting, mainInsetRef, + terminal, }: AppShellChannelSurfaceProps) { return ( @@ -34,7 +36,7 @@ export function AppShellChannelSurface({ style={chromeCssVarDefaults as React.CSSProperties} > {isHuddleRoom && !isHuddleRoomStarting ? : null} - + {isHuddleRoomStarting ? : children} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index 4976fc2ed8..b7912c98bc 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -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} +
+ {children} +
+
+ {terminal} +
); } diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 4c545baf68..358a0e637b 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -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 ? ( + + ) : null; + const channelActions = activeChannel ? ( showJoinButton ? ( ))} @@ -548,20 +645,36 @@ export function TerminalSubstrate({ onClick={() => runTabAction(onNewSession)} type="button" > - + +
- {channelName ? `#${channelName}` : "BUZZ"} - LOCAL PTY · PRIVATE - {shortcutLabel} BUZZ + +
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
{ - 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} />
diff --git a/desktop/src/features/terminal/terminalPanelStore.test.mjs b/desktop/src/features/terminal/terminalPanelStore.test.mjs new file mode 100644 index 0000000000..8a3020f0cb --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/terminal/terminalPanelStore.ts b/desktop/src/features/terminal/terminalPanelStore.ts new file mode 100644 index 0000000000..3c8fa778a7 --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +export type TerminalPanelMode = "closed" | "docked" | "maximized"; + +type Snapshot = { + mode: TerminalPanelMode; + sessionChannelIds: ReadonlySet; +}; + +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) { + 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; +} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index c45bee4136..0d5a103219 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -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"; diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index fe4001a876..27f641578b 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.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; - } -} diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css new file mode 100644 index 0000000000..26b5f42ba8 --- /dev/null +++ b/desktop/src/shared/styles/globals/terminal.css @@ -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; + } + } +} diff --git a/desktop/tests/e2e/terminal-wheel.spec.ts b/desktop/tests/e2e/terminal-wheel.spec.ts index d90fb9da85..c6c6fc5c76 100644 --- a/desktop/tests/e2e/terminal-wheel.spec.ts +++ b/desktop/tests/e2e/terminal-wheel.spec.ts @@ -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"); });