diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index 318e4cb..cfe8b7d 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -3,6 +3,7 @@ | Date | Decision | |------|--------| +| 2026-02-15 | Click-to-focus uses optimistic local state | | 2026-02-15 | Track pane dimensions in paneSizes Map | | 2026-02-14 | Server-side output buffer in DO for reconnect replay | | 2026-02-14 | Render all tabs simultaneously with CSS visibility:hidden on inactive tabs | @@ -24,6 +25,20 @@ For lightweight decisions, a single statement suffices: For significant decisions: +## [2026-02-15-225807] Click-to-focus uses optimistic local state + +**Status**: Accepted + +**Context**: Protocol only supports directional pane_focus (left/right/up/down), no click-by-path + +**Decision**: Click-to-focus uses optimistic local state + +**Rationale**: Simplest fix without protocol changes; server layout_update can override + +**Consequences**: If we later want server-authoritative click focus, add a pane_focus_path message type to the protocol + +--- + ## [2026-02-15-180300] Track pane dimensions in paneSizes Map **Status**: Accepted diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 7401c47..ae2100a 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -3,6 +3,9 @@ | Date | Learning | |------|--------| +| 2026-02-15 | base-ui Tabs.Root needs flex styles in flex parents | +| 2026-02-15 | ghostty-web canvas ref must have zero CSS transitions | +| 2026-02-15 | react-resizable-panels Panel inner div sizing | | 2026-02-15 | paneSizes must be tracked at every terminal creation site | | 2026-02-14 | DO non-hibernation reconnect needs server-side replay | | 2026-02-14 | useMux() object identity churn breaks terminal lifecycle | @@ -13,6 +16,36 @@ | 2026-02-14 | Zensical explicit nav is full override | +## [2026-02-15-225805] base-ui Tabs.Root needs flex styles in flex parents + +**Context**: ChromeBar tab bar floating outside chrome bar + +**Lesson**: base-ui Tabs.Root renders a plain div wrapper. In a flex parent, it needs className with display:flex and flex:1 to stretch properly. + +**Application**: Always add flex styles to Tabs.Root when embedding in a flex layout. + +--- + +## [2026-02-15-225804] ghostty-web canvas ref must have zero CSS transitions + +**Context**: Wormhole terminal garbled after split + +**Lesson**: CSS transitions on the div that ghostty-web attaches to cause garbled canvas on resize. Separate the animation wrapper from the canvas ref div. + +**Application**: When wrapping ghostty-web, put transitions on a parent wrapper, never on the ref div itself. + +--- + +## [2026-02-15-225803] react-resizable-panels Panel inner div sizing + +**Context**: Wormhole UI pane sizing bug + +**Lesson**: react-resizable-panels Panel inner div is not display:flex. Child elements must use width/height:100% instead of flex:1 to fill the panel. + +**Application**: Any component rendered inside a Panel needs explicit percentage sizing, not flex properties. + +--- + ## [2026-02-15-180258] paneSizes must be tracked at every terminal creation site **Context**: Code review caught that handleSessionCreate and handleTabCreate call createTerminalWs without saving to paneSizes, so reconnect falls back to 80x24 diff --git a/CLAUDE.md b/CLAUDE.md index 24e9c14..a318e38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,4 +97,6 @@ biome check --write --staged # before commit | Local Effect source | `~/.local/share/effect-solutions/effect/` | | Local ghostty-web source | `~/code/github.com/usirin/ghostty-web` | | Local effect-atom source | `~/code/github.com/usirin/effect-atom/` | +| CF Sandbox SDK source | `~/code/github.com/usirin/sandbox-sdk/` | +| base-ui docs (LLM-friendly) | `https://base-ui.com/llms.txt` | diff --git a/apps/kamp-us/index.html b/apps/kamp-us/index.html index 1f9f399..04fa5d5 100644 --- a/apps/kamp-us/index.html +++ b/apps/kamp-us/index.html @@ -5,6 +5,9 @@ kamp.us + + +
diff --git a/apps/kamp-us/src/wormhole/ChromeBar.tsx b/apps/kamp-us/src/wormhole/ChromeBar.tsx new file mode 100644 index 0000000..dbd3ba1 --- /dev/null +++ b/apps/kamp-us/src/wormhole/ChromeBar.tsx @@ -0,0 +1,138 @@ +import {Menu} from "@base-ui/react/menu"; +import {Tabs} from "@base-ui/react/tabs"; +import {useMux} from "./MuxClient.tsx"; +import styles from "./WormholeLayout.module.css"; + +export function ChromeBar() { + const {state, createSession, destroySession, createTab, closeTab, switchTab} = useMux(); + + // Derive active session from active tab + const activeTabRecord = state.tabs.find((t) => t.id === state.activeTab); + const activeSessionId = activeTabRecord?.sessionId; + const activeSession = state.sessions.find((s) => s.id === activeSessionId); + + // Tabs for the active session + const visibleTabs = activeSessionId + ? state.tabs.filter((t) => t.sessionId === activeSessionId) + : []; + + return ( +
+ {/* ── Session Selector (left zone) ── */} +
+ wormhole + + + {activeSession?.name ?? "—"} + + + + + + {state.sessions.map((session) => { + const firstTab = state.tabs.find((t) => t.sessionId === session.id); + return ( + { + if (firstTab) switchTab(firstTab.id); + }} + > + {session.name} + + + ); + })} + + createSession(`session-${state.sessions.length + 1}`)} + > + + New Session + + + + + +
+ + {/* ── Tab Bar (middle zone) ── */} + switchTab(value as string)} + > + + {visibleTabs.map((tab) => ( + + {tab.name} + + + ))} + {activeSessionId && ( + + )} + + + + {/* ── Status Dot (right zone) ── */} +
+
+
+
+ ); +} + +function CloseIconSvg() { + return ( + + ); +} diff --git a/apps/kamp-us/src/wormhole/MuxClient.tsx b/apps/kamp-us/src/wormhole/MuxClient.tsx index 7ece0b9..a013a66 100644 --- a/apps/kamp-us/src/wormhole/MuxClient.tsx +++ b/apps/kamp-us/src/wormhole/MuxClient.tsx @@ -1,9 +1,8 @@ // apps/kamp-us/src/wormhole/MuxClient.tsx import {createContext, useContext} from "react"; -import {useWormholeClient} from "./use-wormhole-client.ts"; -import {SessionBar} from "./SessionBar.tsx"; -import {TabBar} from "./TabBar.tsx"; +import {ChromeBar} from "./ChromeBar.tsx"; import {PaneLayout} from "./PaneLayout.tsx"; +import {useWormholeClient} from "./use-wormhole-client.ts"; import styles from "./WormholeLayout.module.css"; type WormholeClient = ReturnType; @@ -24,14 +23,20 @@ export function MuxClient({url, viewport}: MuxClientProps) { const client = useWormholeClient(url, viewport); if (!client.state.connected) { - return
Connecting...
; + return ( +
+
+
+ Connecting... +
+
+ ); } return ( -
- - +
+
diff --git a/apps/kamp-us/src/wormhole/PaneLayout.tsx b/apps/kamp-us/src/wormhole/PaneLayout.tsx index aa76b9b..ced2e01 100644 --- a/apps/kamp-us/src/wormhole/PaneLayout.tsx +++ b/apps/kamp-us/src/wormhole/PaneLayout.tsx @@ -7,10 +7,10 @@ import {TerminalPane} from "./TerminalPane.tsx"; import styles from "./WormholeLayout.module.css"; export function PaneLayout() { - const {state} = useMux(); + const {state, focusPane} = useMux(); return ( -
+
{state.tabs.map((tab) => { const tree = tab.layout as LT.Tree; if (!tree || !tree.root) return null; @@ -27,7 +27,14 @@ export function PaneLayout() { }} > - {renderChildren(tree.root, [], tab.focus, state.channels, state.paneConnected)} + {renderChildren( + tree.root, + [], + tab.focus, + state.channels, + state.paneConnected, + focusPane, + )}
); @@ -42,6 +49,7 @@ function renderChildren( focus: number[], channels: Record, paneConnected: Record, + focusPane: (path: number[]) => void, ) { return stack.children.map((child, i) => { const childPath = [...path, i]; @@ -56,10 +64,17 @@ function renderChildren( )} {child.tag === "window" ? ( - renderWindow(child as LT.Window, childPath, focus, channels, paneConnected) + renderWindow(child as LT.Window, childPath, focus, channels, paneConnected, focusPane) ) : ( - {renderChildren(child as LT.Stack, childPath, focus, channels, paneConnected)} + {renderChildren( + child as LT.Stack, + childPath, + focus, + channels, + paneConnected, + focusPane, + )} )} @@ -74,6 +89,7 @@ function renderWindow( focus: number[], channels: Record, paneConnected: Record, + focusPane: (path: number[]) => void, ) { const channel = channels[window.key]; if (channel === undefined) return
Loading...
; @@ -87,9 +103,7 @@ function renderWindow( sessionId={window.key} focused={isFocused} connected={isConnected} - onFocus={() => { - /* focus is managed by DO */ - }} + onFocus={() => focusPane(path)} /> ); } diff --git a/apps/kamp-us/src/wormhole/SessionBar.tsx b/apps/kamp-us/src/wormhole/SessionBar.tsx deleted file mode 100644 index 6a240cf..0000000 --- a/apps/kamp-us/src/wormhole/SessionBar.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// apps/kamp-us/src/wormhole/SessionBar.tsx -import {useMux} from "./MuxClient.tsx"; - -export function SessionBar() { - const {state, createSession, destroySession, switchTab} = useMux(); - - // Find which session owns the active tab - const activeTabRecord = state.tabs.find((t) => t.id === state.activeTab); - const activeSessionId = activeTabRecord?.sessionId; - - return ( -
- {state.sessions.map((session) => { - const isActive = session.id === activeSessionId; - // Find first tab belonging to this session to switch to it - const firstTab = state.tabs.find((t) => t.sessionId === session.id); - - return ( -
- - -
- ); - })} - -
- ); -} diff --git a/apps/kamp-us/src/wormhole/TabBar.tsx b/apps/kamp-us/src/wormhole/TabBar.tsx deleted file mode 100644 index 268e8c7..0000000 --- a/apps/kamp-us/src/wormhole/TabBar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -// apps/kamp-us/src/wormhole/TabBar.tsx -import {useMux} from "./MuxClient.tsx"; - -export function TabBar() { - const {state, createTab, closeTab, switchTab} = useMux(); - - // Find which session is associated with the active tab - const activeTabRecord = state.tabs.find((t) => t.id === state.activeTab); - const sessionId = activeTabRecord?.sessionId; - - // Show tabs belonging to the active session - const visibleTabs = sessionId ? state.tabs.filter((t) => t.sessionId === sessionId) : []; - - return ( -
- {visibleTabs.map((tab) => ( -
- - -
- ))} - {sessionId && ( - - )} -
- ); -} diff --git a/apps/kamp-us/src/wormhole/TerminalPane.tsx b/apps/kamp-us/src/wormhole/TerminalPane.tsx index d2127ce..0ee9d70 100644 --- a/apps/kamp-us/src/wormhole/TerminalPane.tsx +++ b/apps/kamp-us/src/wormhole/TerminalPane.tsx @@ -20,17 +20,23 @@ export function TerminalPane({ onFocus, theme, }: TerminalPaneProps) { - const {ref} = useChannelTerminal({channel, sessionId, theme}); + const {ref} = useChannelTerminal({channel, sessionId, fontFamily: "JetBrains Mono", theme}); const {splitPane, closePane} = useMux(); return ( // biome-ignore lint/a11y/useKeyWithClickEvents: terminal handles keyboard events via ghostty-web // biome-ignore lint/a11y/noStaticElementInteractions: terminal container, not a button
-
+
+
+
{!connected && (
- Disconnected — press any key to reconnect +
+
+ Disconnected + press any key to reconnect +
)}
@@ -42,7 +48,16 @@ export function TerminalPane({ }} title="Split right" > - | +
diff --git a/apps/kamp-us/src/wormhole/WormholeLayout.module.css b/apps/kamp-us/src/wormhole/WormholeLayout.module.css index 9c001a3..bc38595 100644 --- a/apps/kamp-us/src/wormhole/WormholeLayout.module.css +++ b/apps/kamp-us/src/wormhole/WormholeLayout.module.css @@ -1,35 +1,385 @@ +@import "./wormhole-tokens.css"; + +/* ======================================== + LAYOUT SHELL + ======================================== */ .container { display: flex; flex-direction: column; width: 100%; height: 100vh; - background: #1e1e1e; - color: #d4d4d4; + overflow: hidden; +} + +/* ======================================== + CUSTOM SCROLLBARS + ======================================== */ +.container ::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.container ::-webkit-scrollbar-track { + background: transparent; +} + +.container ::-webkit-scrollbar-thumb { + background: #2a2a2a; + border-radius: 3px; +} + +.container ::-webkit-scrollbar-thumb:hover { + background: #3a3a3a; +} + +.container ::-webkit-scrollbar-corner { + background: transparent; +} + +/* ======================================== + SELECTION + ======================================== */ +.container ::selection { + background: rgba(201, 162, 77, 0.25); + color: var(--wh-text-bright); +} + +/* ======================================== + CLOSE ICON (shared SVG style) + ======================================== */ +.closeIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: none; + background: none; + color: var(--wh-text-muted); + cursor: pointer; + padding: 0; + border-radius: var(--wh-radius); + transition: all 0.12s ease; + flex-shrink: 0; +} + +.closeIcon:hover { + color: var(--wh-danger); + background: rgba(179, 64, 64, 0.1); +} + +.closeIcon svg { + width: 8px; + height: 8px; +} + +/* ======================================== + CHROME BAR (unified session + tabs) + ======================================== */ +.chromeBar { + display: flex; + align-items: stretch; + height: var(--wh-chrome-height); + padding: 0 12px; + background: var(--wh-bg-surface); + border-bottom: 1px solid var(--wh-border); + flex-shrink: 0; + user-select: none; + gap: 0; +} + +/* ── session selector (left zone) ── */ +.sessionSelector { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px 0 0; + margin-right: 4px; + border-right: 1px solid var(--wh-border); + position: relative; + flex-shrink: 0; +} + +.sessionLabel { + font-size: 10px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--wh-text-muted); +} + +.sessionTrigger { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--wh-text-primary); + cursor: pointer; + padding: 4px 6px; + border-radius: var(--wh-radius); + transition: all 0.12s ease; + border: none; + background: none; + font-family: var(--wh-font); +} + +.sessionTrigger:hover { + background: var(--wh-bg-hover); +} + +.chevron { + display: inline-block; + width: 0; + height: 0; + border-left: 3.5px solid transparent; + border-right: 3.5px solid transparent; + border-top: 4px solid var(--wh-text-muted); + transition: transform 0.15s ease; +} + +.sessionTrigger[data-popup-open] .chevron { + transform: rotate(180deg); +} + +/* ── session dropdown ── */ +.sessionPositioner { + z-index: 100; +} + +.sessionPopup { + min-width: 180px; + background: var(--wh-bg-elevated); + border: 1px solid var(--wh-border); + border-radius: var(--wh-radius); + padding: 4px 0; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + transform-origin: var(--transform-origin); + transition: + opacity 0.12s ease, + transform 0.12s ease; +} + +.sessionPopup[data-starting-style], +.sessionPopup[data-ending-style] { + opacity: 0; + transform: scale(0.95); +} + +.sessionItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + font-size: 12px; + color: var(--wh-text-secondary); + cursor: pointer; + transition: all 0.1s ease; +} + +.sessionItem[data-highlighted] { + background: var(--wh-bg-hover); + color: var(--wh-text-primary); +} + +.sessionItem[data-active] { + color: var(--wh-text-primary); +} + +.sessionItem[data-active]::before { + content: ""; + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--wh-accent); + margin-right: 8px; + flex-shrink: 0; +} + +.sessionItem .closeIcon { + opacity: 0; + margin-left: 8px; +} + +.sessionItem:hover .closeIcon, +.sessionItem[data-highlighted] .closeIcon { + opacity: 1; +} + +.sessionDivider { + height: 1px; + background: var(--wh-border); + margin: 4px 0; +} + +.sessionAction { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 11px; + color: var(--wh-text-muted); + cursor: pointer; + transition: all 0.1s ease; +} + +.sessionAction[data-highlighted] { + background: var(--wh-bg-hover); + color: var(--wh-text-secondary); +} + +/* ── tabs (middle zone) ── */ +.tabsRoot { + display: flex; + flex: 1; + min-width: 0; + align-items: stretch; +} + +.tabList { + display: flex; + align-items: stretch; + gap: 0; + flex: 1; + min-width: 0; +} + +.tabItem { + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + font-size: 11px; + color: var(--wh-text-muted); + cursor: pointer; + transition: all 0.12s ease; + position: relative; + border: none; + background: none; + font-family: var(--wh-font); + border-bottom: 1px solid transparent; + margin-bottom: -1px; + flex-shrink: 0; +} + +.tabItem:hover { + color: var(--wh-text-secondary); + background: var(--wh-bg-elevated); +} + +.tabItem[data-active] { + color: var(--wh-text-primary); + background: var(--wh-bg-base); + border-bottom-color: var(--wh-bg-base); +} + +.tabItem .closeIcon { + opacity: 0; + transition: all 0.12s ease; +} + +.tabItem:hover .closeIcon { + opacity: 1; +} + +.tabAdd { + display: flex; + align-items: center; + font-size: 12px; + color: var(--wh-text-muted); + cursor: pointer; + border: none; + background: none; + padding: 0 10px; + font-family: var(--wh-font); + transition: all 0.12s ease; +} + +.tabAdd:hover { + color: var(--wh-text-secondary); +} + +/* ── status dot (right zone) ── */ +.chromeStatus { + display: flex; + align-items: center; + margin-left: auto; + padding-left: 12px; + flex-shrink: 0; } +.statusDot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--wh-accent); + box-shadow: 0 0 4px var(--wh-accent-glow); +} + +.statusDot[data-disconnected] { + background: var(--wh-danger); + box-shadow: 0 0 4px rgba(179, 64, 64, 0.3); + animation: pulseDot 2s ease-in-out infinite; +} + +/* ======================================== + PANE AREA + ======================================== */ +.paneArea { + flex: 1; + position: relative; + min-height: 0; +} + +/* ======================================== + TERMINAL PANE + ======================================== */ .pane { position: relative; display: flex; flex-direction: column; width: 100%; height: 100%; - opacity: 0.65; - transition: opacity 0.15s ease; + background: var(--wh-bg-base); + min-height: 0; + overflow: hidden; + outline: 1px solid transparent; + outline-offset: -1px; } .pane[data-focused] { - opacity: 1; + outline-color: var(--wh-accent); + box-shadow: + inset 0 0 30px rgba(201, 162, 77, 0.03), + 0 0 1px var(--wh-accent); + z-index: 1; +} + +.pane:not([data-focused]) .terminalContent { + opacity: 0.55; } +.pane:hover:not([data-focused]) .terminalContent { + opacity: 0.75; +} + +.terminalContent { + flex: 1; + min-height: 0; +} + +/* ======================================== + PANE CONTROLS + ======================================== */ .paneControls { position: absolute; - top: 4px; - right: 4px; + top: 6px; + right: 6px; z-index: 10; display: flex; gap: 2px; opacity: 0; - transition: opacity 0.15s ease; + transition: opacity 0.12s ease; } .pane:hover .paneControls { @@ -37,51 +387,186 @@ } .paneControls button { - background: rgba(0, 0, 0, 0.6); - border: 1px solid #555; - color: #ccc; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: rgba(14, 14, 14, 0.85); + border: 1px solid var(--wh-border); + color: var(--wh-text-secondary); cursor: pointer; - font-size: 12px; - padding: 2px 6px; - line-height: 1; + border-radius: var(--wh-radius); + transition: all 0.1s ease; + padding: 0; + backdrop-filter: blur(8px); } .paneControls button:hover { - background: rgba(0, 0, 0, 0.8); - color: #fff; + background: var(--wh-bg-hover); + color: var(--wh-text-primary); + border-color: #333; +} + +.paneControls button.closeBtn:hover { + color: var(--wh-danger-hover); + border-color: rgba(179, 64, 64, 0.3); } +.paneControls button svg { + width: 11px; + height: 11px; +} + +/* ======================================== + RESIZE HANDLES + ======================================== */ .resizeHandleH, .resizeHandleV { - background: #333; + background: var(--wh-border); transition: background 0.15s ease; + flex-shrink: 0; + position: relative; +} + +.resizeHandleH { + width: 1px; + cursor: col-resize; + padding: 0 3px; + margin: 0 -3px; +} + +.resizeHandleV { + height: 1px; + cursor: row-resize; + padding: 3px 0; + margin: -3px 0; } .resizeHandleH:hover, .resizeHandleV:hover { - background: #555; + background: var(--wh-accent); } -.resizeHandleH { - width: 2px; +/* grab dots */ +.resizeHandleH::after, +.resizeHandleV::after { + content: ""; + position: absolute; + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; } -.resizeHandleV { - height: 2px; +.resizeHandleH:hover::after, +.resizeHandleV:hover::after { + opacity: 1; +} + +.resizeHandleH::after { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 3px; + height: 20px; + background: + radial-gradient(circle, var(--wh-accent) 1px, transparent 1px) center / 3px 5px repeat-y; } +.resizeHandleV::after { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 20px; + height: 3px; + background: + radial-gradient(circle, var(--wh-accent) 1px, transparent 1px) center / 5px 3px repeat-x; +} + +/* ======================================== + DISCONNECTED OVERLAY + ======================================== */ .disconnectedOverlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.6); + background: rgba(14, 14, 14, 0.75); z-index: 5; + backdrop-filter: blur(1px); +} + +.disconnectedCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.disconnectedDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--wh-accent); + animation: pulseDot 2s ease-in-out infinite; +} + +.disconnectedTitle { + font-size: 12px; + color: var(--wh-text-secondary); + letter-spacing: 0.04em; +} + +.disconnectedHint { + font-size: 10px; + color: var(--wh-text-muted); +} + +/* ======================================== + CONNECTING STATE + ======================================== */ +.connecting { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 12px; +} + +.connectingSpinner { + width: 16px; + height: 16px; + border: 1.5px solid var(--wh-border); + border-top-color: var(--wh-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.connectingText { + font-size: 12px; + color: var(--wh-text-muted); + letter-spacing: 0.04em; +} + +/* ======================================== + ANIMATIONS + ======================================== */ +@keyframes pulseDot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.85); + } } -.disconnectedOverlay span { - color: #888; - font-size: 13px; - font-family: monospace; +@keyframes spin { + to { + transform: rotate(360deg); + } } diff --git a/apps/kamp-us/src/wormhole/use-wormhole-client.ts b/apps/kamp-us/src/wormhole/use-wormhole-client.ts index 5abc8c3..a62e8ee 100644 --- a/apps/kamp-us/src/wormhole/use-wormhole-client.ts +++ b/apps/kamp-us/src/wormhole/use-wormhole-client.ts @@ -52,6 +52,7 @@ interface WormholeClient { closePane: (paneId: string) => void; resizePane: (paneId: string, cols: number, rows: number) => void; moveFocus: (direction: "left" | "right" | "up" | "down") => void; + focusPane: (path: number[]) => void; onTerminalData: (channel: number, callback: (data: Uint8Array) => void) => () => void; } @@ -187,5 +188,10 @@ export function useWormholeClient( closePane: (paneId) => sendControl({type: "pane_close", paneId}), resizePane: (paneId, cols, rows) => sendControl({type: "pane_resize", paneId, cols, rows}), moveFocus: (direction) => sendControl({type: "pane_focus", direction}), + focusPane: (path) => + setState((s) => ({ + ...s, + tabs: s.tabs.map((t) => (t.id === s.activeTab ? {...t, focus: path} : t)), + })), }; } diff --git a/apps/kamp-us/src/wormhole/wormhole-tokens.css b/apps/kamp-us/src/wormhole/wormhole-tokens.css new file mode 100644 index 0000000..effd365 --- /dev/null +++ b/apps/kamp-us/src/wormhole/wormhole-tokens.css @@ -0,0 +1,41 @@ +[data-wormhole] { + /* backgrounds */ + --wh-bg-base: #0e0e0e; + --wh-bg-surface: #141414; + --wh-bg-elevated: #1a1a1a; + --wh-bg-hover: #222; + --wh-bg-active: #1e1e1e; + + /* text */ + --wh-text-primary: #c8c8c8; + --wh-text-secondary: #686868; + --wh-text-muted: #404040; + --wh-text-bright: #e8e8e8; + + /* accent */ + --wh-accent: #c9a24d; + --wh-accent-dim: rgba(201, 162, 77, 0.08); + --wh-accent-hover: #d4ad58; + --wh-accent-glow: rgba(201, 162, 77, 0.15); + + /* borders */ + --wh-border: #1e1e1e; + --wh-border-subtle: #1a1a1a; + + /* danger */ + --wh-danger: #b34040; + --wh-danger-hover: #cc4c4c; + + /* typography */ + --wh-font: "JetBrains Mono", monospace; + + /* dimensions */ + --wh-chrome-height: 34px; + --wh-radius: 2px; + + /* base styles */ + font-family: var(--wh-font); + background: var(--wh-bg-base); + color: var(--wh-text-primary); + -webkit-font-smoothing: antialiased; +} diff --git a/docs/blog/2026-02-15-listening-to-judge.md b/docs/blog/2026-02-15-listening-to-judge.md new file mode 100644 index 0000000..b4abb8b --- /dev/null +++ b/docs/blog/2026-02-15-listening-to-judge.md @@ -0,0 +1,143 @@ +--- +title: "You're Not Listening to Understand, You're Listening to Judge" +date: 2026-02-15 +author: Umut Sirin +topics: + - communication + - listening + - systems-thinking +--- + +# You're Not Listening to Understand, You're Listening to Judge + +A friend was telling me about a decision he'd made at work. +Something about reorganizing his team's review process. He was +two sentences in. I was already building my counter-argument. + +Not consciously. I didn't decide to stop listening. But +somewhere between his first sentence and his second, my brain +classified what he was saying, found it insufficient, and +started drafting a response. By the time he finished, I had a +fully formed rebuttal to a point he hadn't actually made. + +Then he said something I didn't expect. It didn't fit the +argument I'd been preparing against. I asked him to repeat +himself. He paused, then did. This time I heard him. His point +was more interesting than the one I'd been arguing with in my +head. + +That's the moment I started paying attention to this: the quiet +click where listening turns into judgment. Where incoming speech +stops being information and starts being a test you're grading. + +## The Closed Loop + +Walk into a conversation with a fixed model of the topic, the +person, or what the right answer looks like, and the +conversation becomes a closed system. Their words get filtered +through your existing frame. Confirmations pass through easily. +Contradictions get flagged, resisted, or quietly discarded. + +You leave with the same model you brought in. So does the other +person. Twenty minutes of sound, zero information generated. + +It looks exactly like engagement. Nodding. Responding. Even +asking questions. But you're not asking "what do you mean?" +You're asking "how wrong are you?" + +## Both Sides of the Table + +Being on the receiving end produces a specific sensation. Not +anger. More like talking into a void that has opinions. You can +feel the other person's attention narrow. Their responses come +too quickly, as if pre-loaded. They address the thing you said +four sentences ago, not the thing you just said. + +The tell is the "yeah, but." Two words that mean: I registered +enough of your point to dismiss it, and now I'm going to say +what I was going to say anyway. + +Being on the giving end is harder to see because it doesn't feel +like disengagement. It feels productive. You're scanning, +testing, preparing. From the inside, this feels like active +listening. From the outside, it looks like a closed door. + +I catch myself doing it most when I'm confident about the topic. +My brain shifts from "what are they saying?" to "where are they +wrong?" The switch is so fast I only notice it after, when I +realize I can't remember the second half of what they said. + +## What Understanding Looks Like + +Understanding-listening has observable markers. + +**The pause.** A beat after someone finishes speaking. Not +performance silence; the listener is processing what was said +rather than executing a pre-formed response. Speed of response +is usually inversely proportional to how much listening +happened. + +**The follow-up question.** A question that couldn't have been +asked without hearing what was just said. "When you say X, do +you mean A or B?" is understanding. "But don't you think X is +wrong because Y?" is judgment wearing a question mark. + +**The model-update signal.** The rarest one. "I hadn't thought +of it that way." "That changes how I see it." Even just "huh." +The listener acknowledging that their model was modified by the +input. Most people would rather appear consistently right than +visibly learn. + +**The accurate summary.** If you can restate someone's point in +a way they agree with, you understood. If your summary distorts +their point into something easier to argue against, you were +judging. Simplest test. Most people fail it. + +## Why It Persists + +Two forces keep judgment-listening in place. + +The first is status. The person who evaluates is perceived as +higher-status than the person being evaluated. Judgment-listening +positions you as assessor, not student. We reward fast, confident +opinions. The person at the dinner table who says "I'm still +thinking about that" gets less airtime than the person who +immediately counters with a take. + +The second is that it works, for you, in the moment. It keeps +your model stable, your confidence intact, makes you feel +prepared and in control. The payoff is real, even if the cost is +deferred. + +The cost shows up in conversations that go nowhere. In +relationships where people stop sharing what they actually think. +In the slow sense that you're talking to people all day and +nobody is really saying anything. Judgment-listening doesn't +break conversations. It hollows them out. + +## The Hard Part + +Judgment-listening isn't always wrong. A doctor diagnosing a +patient, a senior engineer reviewing code, a parent parsing an +excuse: there are contexts where rapid evaluation is exactly the +right tool. The problem isn't that we judge. The problem is that +judgment is the default mode even when understanding would serve +better. + +And understanding-listening is genuinely costly. It means holding +your model in flux instead of settled. Letting in information +that might prove you wrong. Being slower than the culture +rewards. I know this because I still fail at it constantly. I +know the pattern, I can describe it in detail, and I still catch +myself mid-conversation building a rebuttal to a point someone +hasn't finished making. + +The quality of a conversation is set before anyone speaks, by +the mode of listening each person brings. Two people who +disagree but listen to understand will generate more insight in +ten minutes than two people who agree on everything but listen +to judge will generate in an hour. + +I don't think most people intend to listen to judge. They intend +to listen, and judgment is what their brain does with the input +by default. Noticing the switch is the work. diff --git a/docs/blog/index.md b/docs/blog/index.md index c343b6a..a656c55 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -147,3 +147,21 @@ message. No reconnect button. You just type. **Topics**: wormhole, cloudflare-sandbox, reconnection, protocol-design + +--- + +## Standalone Essays + +--- + +### [You're Not Listening to Understand, You're Listening to Judge](2026-02-15-listening-to-judge.md) + +*Umut Sirin / 2026-02-15* + +Neutral listening doesn't exist. Your brain sorts incoming speech +into agree/disagree before the sentence ends. This essay breaks +down the mechanics of judgment-listening as a closed feedback loop, +why it persists, what understanding-listening actually looks like, +and why the alternative is genuinely costly. + +**Topics**: communication, listening, systems-thinking diff --git a/docs/mockups/terminal-refined-industrial.html b/docs/mockups/terminal-refined-industrial.html new file mode 100644 index 0000000..52a0917 --- /dev/null +++ b/docs/mockups/terminal-refined-industrial.html @@ -0,0 +1,1004 @@ + + + + + +Wormhole — Refined Industrial + + + + + + + +
+ + +
+ + +
+ wormhole + + + +
+
+ kampus + +
+
+ sandbox-sdk + +
+
+
+ + New Session +
+
+
+ + +
+
+ dev + +
+
+ logs + +
+
+ git + +
+ +
+ + +
+
+
+ +
+ + +
+ + +
+
+
~/kampus on umut/stale-sessions via node v22
+$ turbo run typecheck
+
+  Tasks:    3 successful, 3 total
+  Cached:   2 cached, 3 total
+  Time:     4.2s
+
+~/kampus on umut/stale-sessions
+$ git log --oneline -5
+
+e7fe645 feat(sandbox): Wormhole protocol: mux server over CF Sandbox
+491d019 feat(wormhole): Phase 1: Immortal Sessions
+89e2008 initialize ctx
+5c5b86b feat(wormhole): multiplexed terminal sessions
+5d6b86c feat: wormhole: personal multi-device terminal
+
+~/kampus on umut/stale-sessions
+$ 
+
+ +
+ + + +
+
+ +
+ + +
+ + +
+
+
~/kampus on umut/stale-sessions
+$ pnpm turbo run dev --filter=@kampus/worker
+
+@kampus/worker:dev: ready in 1.2s
+@kampus/worker:dev: listening on http://localhost:8787
+@kampus/worker:dev:
+@kampus/worker:dev: [mf:inf] GET /sandbox/ws 101 Switching Protocols
+@kampus/worker:dev: [mf:inf] GET /sandbox/ws 101 Switching Protocols
+@kampus/worker:dev: [WormholeServer] client connected, 2 sessions
+@kampus/worker:dev: [WormholeServer] terminal WS opened for pty-a1b2
+@kampus/worker:dev: [WormholeServer] terminal WS opened for pty-c3d4
+@kampus/worker:dev: [WormholeServer] terminal WS closed for pty-e5f6
+@kampus/worker:dev: [WormholeServer] reconnecting pty-e5f6...
+@kampus/worker:dev: [WormholeServer] reconnected pty-e5f6
+
+ +
+ + + +
+
+ +
+ + +
+
+
~/sandbox-sdk on main
+$ pnpm test
+
+ PASS  src/sandbox.test.ts
+   creates sandbox instance (12ms)
+   opens terminal session (8ms)
+   reconnects after sleep (45ms)
+   buffers output on disconnect (23ms)
+
+Tests:   4 passed, 4 total
+Time:    0.8s
+
+ +
+
+
+ Disconnected + press any key to reconnect +
+
+ +
+ + + +
+
+ +
+ +
+ +
+ + + + + diff --git a/docs/plans/2026-02-15-wormhole-ui-fixes.md b/docs/plans/2026-02-15-wormhole-ui-fixes.md new file mode 100644 index 0000000..8cfb6b2 --- /dev/null +++ b/docs/plans/2026-02-15-wormhole-ui-fixes.md @@ -0,0 +1,129 @@ +# Wormhole UI Redesign — Fix Pass + +> **For Claude:** Read the mockup at +> `docs/mockups/terminal-refined-industrial.html` and compare against +> the current implementation. Use the frontend-design skill. + +**Goal:** Fix visual issues from the initial UI redesign implementation +on branch `umut/wormhole-ui-redesign`. + +**Context:** The redesign landed in 6 commits but has issues: +1. Terminal text garbles after split operations +2. Colors/background may not match mockup +3. Overall polish gap + +--- + +## Bug 1: Garbled terminal text after pane split + +**Symptom:** After splitting a pane, the terminal text in the +existing (resized) pane becomes garbled/corrupted. + +**Likely cause:** The pane agent changed the terminal container from +inline styles to a CSS class: + +```tsx +// BEFORE (worked): +
+ +// AFTER (garbles on split): +
+``` + +The `.terminalContent` CSS class is: +```css +.terminalContent { + flex: 1; + min-height: 0; + transition: opacity 0.2s ease; +} +``` + +Possible issues: +1. The `transition: opacity 0.2s ease` might interfere with + ghostty-web's layout measurement during resize +2. The class might be missing `width: 100%` or `height: 100%` + that the flex layout needs explicitly for canvas sizing +3. The opacity transition on the parent might cause ghostty-web + to mis-measure during the split animation + +**Investigation steps:** +1. Check if removing `transition` from `.terminalContent` fixes it +2. Check if adding explicit `width: 100%; height: 100%` fixes it +3. Check if the bug exists on `main` branch (pre-redesign) too +4. Check ghostty-web's resize observer — does it fire correctly + when the flex container resizes from a split? + +**Fix approach:** Move the opacity transition to a wrapper div +instead of the terminal container. The `ref` div that ghostty-web +attaches to should have zero CSS interference: + +```tsx +
+
+
+``` + +Or keep inline styles on the ref div and use the class only for +the opacity effect on a wrapper. + +--- + +## Bug 2: Background/color mismatch + +**Symptom:** Page background may not be #0e0e0e from tokens. + +**Investigation steps:** +1. Open DevTools, inspect `[data-wormhole]` — are the CSS custom + properties actually set? +2. Check if `@import "./wormhole-tokens.css"` inside the CSS module + is being processed correctly by Vite +3. Check if body/html styles from `index.css` or radix colors are + bleeding through + +**Possible fix:** If the import isn't working, move the +`[data-wormhole]` styles into the module CSS directly, or import +the tokens file from a global CSS file instead. + +**Alternative:** Set explicit `background: var(--wh-bg-base)` on +`.container` as a fallback. + +--- + +## Bug 3: Visual polish pass + +**Approach:** Open the mockup HTML and the running app side by side. +Compare element by element: + +- [ ] Chrome bar height, padding, background color +- [ ] Session label font size, weight, letter-spacing, color +- [ ] Session trigger hover state +- [ ] Tab item: inactive color, active background-blend pattern +- [ ] Tab close button visibility on hover +- [ ] Status dot color and glow +- [ ] Pane focus: amber outline + subtle glow visible? +- [ ] Unfocused pane dimming (opacity 0.55) +- [ ] Pane controls: backdrop blur, SVG icon sizes +- [ ] Resize handle: accent on hover, grab dots +- [ ] Disconnected overlay: blur, pulsing dot +- [ ] Connecting spinner +- [ ] Page load animations +- [ ] Custom scrollbars +- [ ] JetBrains Mono rendering in chrome text + +Fix any mismatches found. The mockup HTML is the source of truth. + +--- + +## Files to check + +| File | What to look at | +|------|-----------------| +| `apps/kamp-us/src/wormhole/wormhole-tokens.css` | Token values match mockup | +| `apps/kamp-us/src/wormhole/WormholeLayout.module.css` | All styles match mockup | +| `apps/kamp-us/src/wormhole/TerminalPane.tsx` | Terminal ref div setup | +| `apps/kamp-us/src/wormhole/ChromeBar.tsx` | Component structure | +| `apps/kamp-us/src/wormhole/MuxClient.tsx` | data-wormhole attribute | +| `apps/kamp-us/src/wormhole/PaneLayout.tsx` | paneArea class | +| `apps/kamp-us/src/index.css` | Body styles that might conflict | +| `docs/mockups/terminal-refined-industrial.html` | Source of truth | diff --git a/docs/plans/2026-02-15-wormhole-ui-redesign.md b/docs/plans/2026-02-15-wormhole-ui-redesign.md new file mode 100644 index 0000000..e8dd5c6 --- /dev/null +++ b/docs/plans/2026-02-15-wormhole-ui-redesign.md @@ -0,0 +1,825 @@ +# Wormhole UI Redesign — Refined Industrial + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans +> to implement this plan task-by-task. + +**Goal:** Replace the unstyled wormhole terminal UI with the Refined +Industrial design from `docs/mockups/terminal-refined-industrial.html`, +using base-ui components for interactive chrome. + +**Architecture:** Merge SessionBar + TabBar into a single ChromeBar +component. Session selector becomes a base-ui Menu dropdown. Tab bar +becomes base-ui Tabs (controlled, no panels). TerminalPane gets SVG +icon buttons, focus glow, and a styled disconnected overlay. All +styling lives in CSS Modules with wormhole-scoped design tokens. + +**Tech Stack:** React 19, `@base-ui/react` (Menu, Tabs), +`react-resizable-panels`, `ghostty-web`, CSS Modules, JetBrains Mono + +**Skills:** Agents doing UI/CSS work MUST have the +`frontend-design:frontend-design` skill loaded for design quality +guidance. + +**Reference:** `docs/mockups/terminal-refined-industrial.html` (the +complete HTML mockup — consult for exact token values, class names, +and visual behavior) + +--- + +## Task 1: Add JetBrains Mono font + +**Files:** +- Modify: `apps/kamp-us/index.html` + +**Step 1: Add Google Fonts preconnect and stylesheet link** + +Add before `` in `index.html`: + +```html + + + +``` + +**Step 2: Verify** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS (no type changes, just HTML) + +**Step 3: Commit** + +```bash +git add apps/kamp-us/index.html +git commit -m "feat(wormhole): add JetBrains Mono font import" +``` + +--- + +## Task 2: Wormhole design tokens + +**Files:** +- Create: `apps/kamp-us/src/wormhole/wormhole-tokens.css` +- Modify: `apps/kamp-us/src/wormhole/WormholeLayout.module.css` + +**Step 1: Create the tokens file** + +Create `wormhole-tokens.css` with all design tokens from the mockup. +This file defines a `.wormhole` scope so tokens don't leak into the +rest of the app: + +```css +.wormhole { + /* backgrounds */ + --wh-bg-base: #0e0e0e; + --wh-bg-surface: #141414; + --wh-bg-elevated: #1a1a1a; + --wh-bg-hover: #222; + + /* text */ + --wh-text-primary: #c8c8c8; + --wh-text-secondary: #686868; + --wh-text-muted: #404040; + --wh-text-bright: #e8e8e8; + + /* accent */ + --wh-accent: #c9a24d; + --wh-accent-dim: rgba(201, 162, 77, 0.08); + --wh-accent-glow: rgba(201, 162, 77, 0.15); + + /* borders */ + --wh-border: #1e1e1e; + + /* danger */ + --wh-danger: #b34040; + --wh-danger-hover: #cc4c4c; + + /* typography */ + --wh-font: "JetBrains Mono", monospace; + + /* dimensions */ + --wh-chrome-height: 34px; + --wh-radius: 2px; + + /* apply base styles */ + font-family: var(--wh-font); + background: var(--wh-bg-base); + color: var(--wh-text-primary); + -webkit-font-smoothing: antialiased; +} +``` + +**Step 2: Import tokens in the CSS module** + +Replace the entire `WormholeLayout.module.css` with just an import and +the `.container` class using the `.wormhole` scope. The other classes +will be added in later tasks: + +```css +@import "./wormhole-tokens.css"; + +.container { + composes: wormhole from "./wormhole-tokens.css"; + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; + overflow: hidden; +} +``` + +Wait — CSS Modules `composes` from a non-module file can be tricky. +Instead, apply the `.wormhole` class via the container element and +import the tokens file normally. The `.container` in the module just +handles layout; the tokens file is a plain CSS file imported for its +custom properties. + +Revised approach: Make the tokens a regular CSS file that sets +properties on a data attribute selector: + +```css +/* wormhole-tokens.css */ +[data-wormhole] { + /* ... all tokens ... */ +} +``` + +Then in `WormholeLayout.module.css`, just import it and reference +the variables. MuxClient adds `data-wormhole` to the root div. + +**Step 3: Verify** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/kamp-us/src/wormhole/wormhole-tokens.css +git add apps/kamp-us/src/wormhole/WormholeLayout.module.css +git commit -m "feat(wormhole): add design tokens and base container styles" +``` + +--- + +## Task 3: Restyle container, pane, and resize handles + +**Files:** +- Modify: `apps/kamp-us/src/wormhole/WormholeLayout.module.css` + +**Step 1: Replace all styles** + +The full CSS module. Match the mockup exactly. Key classes: + +- `.container` — flex column, full viewport, imports tokens +- `.pane` — relative, flex column, transparent outline that becomes + accent on `[data-focused]`, with glow box-shadow +- `.pane:not([data-focused]) .terminalContent` — opacity 0.55 + (not the whole pane — just the terminal content area) +- `.paneControls` — absolute top-right, opacity 0 until pane hover, + buttons with backdrop blur and SVG sizing +- `.paneControls .closeBtn:hover` — danger color +- `.resizeHandleH` / `.resizeHandleV` — 1px, accent on hover, `::after` + pseudo-element for grab dots (radial-gradient) +- `.disconnectedOverlay` — absolute inset, centered card with pulsing + dot, backdrop blur +- `.connecting` — full-height centered spinner + text + +Consult the mockup for exact values. Use `var(--wh-*)` tokens +everywhere. + +**Step 2: Verify** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +Run: `pnpm biome check apps/kamp-us/src/wormhole/WormholeLayout.module.css` +Expected: PASS (or no CSS errors) + +**Step 3: Commit** + +```bash +git add apps/kamp-us/src/wormhole/WormholeLayout.module.css +git commit -m "feat(wormhole): restyle panes, resize handles, overlays" +``` + +--- + +## Task 4: TerminalPane — SVG icons, focus glow, styled overlay + +**Files:** +- Modify: `apps/kamp-us/src/wormhole/TerminalPane.tsx` + +**Step 1: Replace text buttons with SVG icon buttons** + +Replace the `|`, `—`, `×` text in the pane control buttons with +inline SVG elements matching the mockup: + +- **Split Right:** Two side-by-side rectangles + ```tsx + + + + + ``` + +- **Split Down:** Two stacked rectangles + ```tsx + + + + + ``` + +- **Close:** X mark (add `className={styles.closeBtn}`) + ```tsx + + + + + ``` + +**Step 2: Restyle disconnected overlay** + +Replace the plain text overlay with the card design from the mockup: + +```tsx +{!connected && ( +
+
+
+ Disconnected + + press any key to reconnect + +
+
+)} +``` + +**Step 3: Wrap terminal ref div with a className** + +The `ref` div needs a class so CSS can target terminal content opacity: + +```tsx +
+``` + +Keep `style={{flex: 1, minHeight: 0}}` or move those to the CSS class. + +**Step 4: Pass JetBrains Mono to the terminal canvas** + +`useChannelTerminal` accepts `fontFamily` as an optional prop. +Pass it from TerminalPane so the terminal canvas matches the chrome: + +```tsx +const {ref} = useChannelTerminal({ + channel, + sessionId, + fontFamily: "JetBrains Mono", + theme, +}); +``` + +**Step 5: Verify** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +**Step 6: Commit** + +```bash +git add apps/kamp-us/src/wormhole/TerminalPane.tsx +git commit -m "feat(wormhole): SVG pane controls and styled overlay" +``` + +--- + +## Task 5: ChromeBar — unified session selector + tabs + +**Files:** +- Create: `apps/kamp-us/src/wormhole/ChromeBar.tsx` +- Modify: `apps/kamp-us/src/wormhole/WormholeLayout.module.css` + +This is the most complex task. The ChromeBar merges SessionBar and +TabBar into one bar with three zones: + +``` +[SESSION ▾ | tab1 tab2 tab3 + | ●] +``` + +**Step 1: Create ChromeBar.tsx** + +Use base-ui Menu directly (not the design system Menu wrapper, since +wormhole has its own styling): + +```tsx +import {Menu} from "@base-ui/react/menu"; +import {Tabs} from "@base-ui/react/tabs"; +import {useMux} from "./MuxClient.tsx"; +import styles from "./WormholeLayout.module.css"; + +export function ChromeBar() { + const { + state, + createSession, + destroySession, + createTab, + closeTab, + switchTab, + } = useMux(); + + // Derive active session from active tab + const activeTabRecord = state.tabs.find( + (t) => t.id === state.activeTab, + ); + const activeSessionId = activeTabRecord?.sessionId; + const activeSession = state.sessions.find( + (s) => s.id === activeSessionId, + ); + + // Tabs for the active session + const visibleTabs = activeSessionId + ? state.tabs.filter((t) => t.sessionId === activeSessionId) + : []; + + return ( +
+ {/* ── Session Selector (left zone) ── */} +
+ wormhole + + + {activeSession?.name ?? "—"} + + + + + + {state.sessions.map((session) => { + const firstTab = state.tabs.find( + (t) => t.sessionId === session.id, + ); + return ( + { + if (firstTab) switchTab(firstTab.id); + }} + > + {session.name} + + + ); + })} + + + createSession( + `session-${state.sessions.length + 1}`, + ) + } + > + + New Session + + + + + +
+ + {/* ── Tab Bar (middle zone) ── */} + {/* Tabs.Tab.Value is `any | null` — string IDs work. + onValueChange receives (value, eventDetails). */} + switchTab(value as string)} + > + {/* Tabs.List renders a
and accepts any ReactNode + children, so the + button can live inside it. */} + + {visibleTabs.map((tab) => ( + + {tab.name} + + + ))} + {activeSessionId && ( + + )} + + + + {/* ── Status Dot (right zone) ── */} +
+
+
+
+ ); +} + +function CloseIconSvg() { + return ( + + + + + ); +} +``` + +**Step 2: Add ChromeBar styles to WormholeLayout.module.css** + +Add all chrome bar classes. Key patterns from the mockup: + +- `.chromeBar` — flex, stretch, 34px height, surface bg, bottom border +- `.sessionSelector` — flex, right border separator +- `.sessionLabel` — 10px uppercase muted text +- `.sessionTrigger` — 12px, no border/bg, hover bg +- `.chevron` — CSS triangle (border trick) +- `.sessionPositioner` — z-index 100 +- `.sessionPopup` — elevated bg, border, shadow, scale+opacity + transition using `[data-starting-style]`/`[data-ending-style]` +- `.sessionItem` — flex between, hover bg, `[data-active]::before` + accent dot +- `.sessionItem .closeIcon` — opacity 0, visible on item hover +- `.sessionDivider` — 1px border line +- `.sessionAction` — muted, smaller text +- `.tabList` — flex stretch, flex 1 +- `.tabItem` — 11px, muted text, `[data-active]` gets bg-base + + border-bottom matching bg-base + margin-bottom -1px + (the background-blend pattern) +- `.tabItem .closeIcon` — opacity 0 until tab hover +- `.tabAdd` — muted +, hover secondary +- `.chromeStatus` — margin-left auto, flex center +- `.statusDot` — 5px circle, accent bg + glow shadow, + `[data-disconnected]` danger + pulse animation +- `.closeIcon` — shared: 14px square, muted color, danger on hover + +Note on base-ui data attributes (from https://base-ui.com/llms.txt): +- Tabs uses `[data-active]` for the active tab +- Menu uses `[data-highlighted]` for keyboard/hover focus +- Menu uses `[data-popup-open]` on the trigger when popup is visible +- Menu uses `[data-starting-style]`/`[data-ending-style]` for + enter/exit animations +- CSS variable `--transform-origin` available on Menu.Popup for + scale animations + +**Step 3: Verify** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/kamp-us/src/wormhole/ChromeBar.tsx +git add apps/kamp-us/src/wormhole/WormholeLayout.module.css +git commit -m "feat(wormhole): ChromeBar with session dropdown and tabs" +``` + +--- + +## Task 6: Wire up MuxClient and add connecting state + +**Files:** +- Modify: `apps/kamp-us/src/wormhole/MuxClient.tsx` + +**Step 1: Replace SessionBar + TabBar with ChromeBar** + +```tsx +import {ChromeBar} from "./ChromeBar.tsx"; +// Remove: import {SessionBar} from "./SessionBar.tsx"; +// Remove: import {TabBar} from "./TabBar.tsx"; +``` + +Update the render: + +```tsx +return ( + +
+ + +
+
+); +``` + +Note: `data-wormhole` activates the design tokens from +`wormhole-tokens.css`. + +**Step 2: Style the connecting state** + +Replace the plain text with a styled spinner: + +```tsx +if (!client.state.connected) { + return ( +
+
+
+ Connecting... +
+
+ ); +} +``` + +Add to CSS: + +```css +.connecting { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 12px; +} + +.connectingSpinner { + width: 16px; + height: 16px; + border: 1.5px solid var(--wh-border); + border-top-color: var(--wh-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.connectingText { + font-size: 12px; + color: var(--wh-text-muted); + letter-spacing: 0.04em; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} +``` + +**Step 3: Verify** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/kamp-us/src/wormhole/MuxClient.tsx +git add apps/kamp-us/src/wormhole/WormholeLayout.module.css +git commit -m "feat(wormhole): wire ChromeBar and styled connecting state" +``` + +--- + +## Task 7: Delete old components and verify + +**Files:** +- Delete: `apps/kamp-us/src/wormhole/SessionBar.tsx` +- Delete: `apps/kamp-us/src/wormhole/TabBar.tsx` + +**Step 1: Delete the files** + +```bash +rm apps/kamp-us/src/wormhole/SessionBar.tsx +rm apps/kamp-us/src/wormhole/TabBar.tsx +``` + +**Step 2: Verify no remaining imports** + +Search for any references to the deleted files: + +```bash +grep -r "SessionBar\|TabBar" apps/kamp-us/src/ +``` + +Expected: No results (MuxClient no longer imports them). + +**Step 3: Full verification** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +Run: `pnpm biome check apps/kamp-us/src/wormhole/` +Expected: PASS + +**Step 4: Commit** + +```bash +git add -u apps/kamp-us/src/wormhole/ +git commit -m "refactor(wormhole): delete SessionBar and TabBar" +``` + +--- + +## Task 8: Custom scrollbars and selection highlight + +**Files:** +- Modify: `apps/kamp-us/src/wormhole/WormholeLayout.module.css` + +**Step 1: Add scoped scrollbar styles** + +Inside the `[data-wormhole]` scope in `wormhole-tokens.css`, or in +the CSS module targeting `.container`: + +```css +.container ::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.container ::-webkit-scrollbar-track { + background: transparent; +} + +.container ::-webkit-scrollbar-thumb { + background: #2a2a2a; + border-radius: 3px; +} + +.container ::-webkit-scrollbar-thumb:hover { + background: #3a3a3a; +} + +.container ::selection { + background: rgba(201, 162, 77, 0.25); + color: var(--wh-text-bright); +} +``` + +**Step 2: Verify + Commit** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +```bash +git add apps/kamp-us/src/wormhole/WormholeLayout.module.css +git commit -m "feat(wormhole): custom scrollbars and selection highlight" +``` + +--- + +## Task 9: Page load animations + +**Files:** +- Modify: `apps/kamp-us/src/wormhole/WormholeLayout.module.css` + +**Step 1: Add staggered entry animations** + +```css +.chromeBar { + animation: fadeDown 0.3s ease both; + animation-delay: 0.1s; +} + +/* Target the pane area wrapper in PaneLayout */ +.paneArea { + animation: fadeUp 0.4s ease both; + animation-delay: 0.2s; +} + +@keyframes fadeDown { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeUp { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +``` + +The `paneArea` class needs to be applied in PaneLayout.tsx to the +outer wrapper div. + +**Step 2: Add paneArea class to PaneLayout** + +In `PaneLayout.tsx`, change the outer div: + +```tsx +
+``` + +Or move flex/position into the CSS class itself. + +**Step 3: Verify + Commit** + +Run: `turbo run typecheck --filter=@kampus/kamp-us` +Expected: PASS + +```bash +git add apps/kamp-us/src/wormhole/WormholeLayout.module.css +git add apps/kamp-us/src/wormhole/PaneLayout.tsx +git commit -m "feat(wormhole): staggered page load animations" +``` + +--- + +## Task 10: Visual verification + +**Step 1: Start dev server** + +```bash +pnpm turbo run dev --filter=@kampus/kamp-us +``` + +**Step 2: Manual checks** + +Open the wormhole page in the browser and verify: + +- [ ] JetBrains Mono renders for all chrome text +- [ ] Unified chrome bar: session dropdown on left, tabs in middle, + status dot on right +- [ ] Session dropdown opens/closes, switches sessions, shows active + dot, has close buttons +- [ ] Background-blend tab pattern: active tab bg matches terminal area +- [ ] Close buttons appear on tab hover +- [ ] Pane focus: accent outline + subtle glow on focused pane +- [ ] Unfocused panes: dimmed terminal content (opacity 0.55) +- [ ] Pane controls: appear on hover, SVG icons, backdrop blur +- [ ] Close button hover: danger red +- [ ] Resize handles: accent color on hover, grab dots appear +- [ ] Disconnected overlay: pulsing dot, centered card +- [ ] Connecting state: spinner + text +- [ ] Custom scrollbars +- [ ] Page load: staggered fade-down/fade-up animation + +**Step 3: Fix any visual issues** + +Iterate on CSS values if anything doesn't match the mockup. + +**Step 4: Final commit if adjustments were needed** + +```bash +git add -u apps/kamp-us/src/wormhole/ +git commit -m "fix(wormhole): visual polish adjustments" +``` + +--- + +## Resolved Questions + +1. **base-ui Tabs `onValueChange`:** Signature is + `(value: Tabs.Tab.Value, eventDetails)` where `Value = any | null`. + String tab IDs work. We ignore `eventDetails`. +2. **`+ New tab` button:** Goes inside `Tabs.List`. The List renders + a `
` and accepts any `ReactNode` children (confirmed from + type definitions: `BaseUIComponentProps<'div', ...>`). +3. **Ghostty terminal font:** `useChannelTerminal` accepts `fontFamily` + prop (optional). We pass `"JetBrains Mono"` from TerminalPane (Task 4). diff --git a/packages/sandbox/src/ChannelMap.ts b/packages/sandbox/src/ChannelMap.ts index 21defb0..0d4a1ae 100644 --- a/packages/sandbox/src/ChannelMap.ts +++ b/packages/sandbox/src/ChannelMap.ts @@ -1,5 +1,19 @@ +/** + * @module + * + * Bidirectional map between PTY IDs and single-byte channel numbers (0–254). + * Channel 255 is reserved for control messages (see {@link Protocol}). + * The 255-channel limit comes from the binary frame format: one byte per channel. + */ + const DEFAULT_MAX_CHANNELS = 255; // 0-254, channel 255 reserved for control +/** + * Bidirectional map from PTY IDs (strings) to channel numbers (0–254). + * + * Channels are assigned sequentially, and released channels are recycled + * via a free-list so IDs stay compact. + */ export class ChannelMap { private channelToPty = new Map(); private ptyToChannel = new Map(); @@ -11,6 +25,7 @@ export class ChannelMap { this.maxChannels = maxChannels; } + /** Assign a channel to `ptyId`. Idempotent — returns the existing channel if already assigned. Returns `null` when all channels are exhausted. */ assign(ptyId: string): number | null { const existing = this.ptyToChannel.get(ptyId); if (existing !== undefined) return existing; @@ -29,6 +44,7 @@ export class ChannelMap { return channel; } + /** Release a channel, returning it to the free-list for reuse. No-op if the channel is unassigned. */ release(channel: number): void { const ptyId = this.channelToPty.get(channel); if (ptyId === undefined) return; @@ -37,14 +53,17 @@ export class ChannelMap { this.freeList.push(channel); } + /** Look up the PTY ID for a channel, or `null` if unassigned. */ getPtyId(channel: number): string | null { return this.channelToPty.get(channel) ?? null; } + /** Look up the channel for a PTY ID, or `null` if unassigned. */ getChannel(ptyId: string): number | null { return this.ptyToChannel.get(ptyId) ?? null; } + /** Serialize to a plain object (`{ ptyId: channel }`) for persistence. */ toRecord(): Record { const record: Record = {}; for (const [ptyId, channel] of this.ptyToChannel) { @@ -53,6 +72,7 @@ export class ChannelMap { return record; } + /** Reconstruct from a serialized record. Sets `nextChannel` past the highest seen value; does not rebuild the free-list (gaps become permanently lost). */ static fromRecord( record: Record, maxChannels: number = DEFAULT_MAX_CHANNELS, diff --git a/packages/sandbox/src/Errors.ts b/packages/sandbox/src/Errors.ts index ed9a4f2..3bbad9a 100644 --- a/packages/sandbox/src/Errors.ts +++ b/packages/sandbox/src/Errors.ts @@ -1,32 +1,47 @@ -// packages/sandbox/src/Errors.ts +/** + * @module + * + * Error types for the sandbox package. Two styles coexist: + * + * - **Schema TaggedErrors** (`TerminalError`, `SandboxError`, `ExecError`, `FileSystemError`): + * Effect-native errors used by the {@link Sandbox} service interface. They are + * schema-encoded, so they survive serialization across Worker boundaries. + * + * - **Plain TS errors** (`ChannelExhaustedError`, `SandboxSleepError`, etc.): + * Lightweight errors used by Wormhole session/channel management where + * schema encoding isn't needed. + */ import {Schema} from "effect"; -// ── Old errors (Schema TaggedError) ───────────────────────── -// Used by Sandbox.ts and SandboxLive.ts +// ── Schema TaggedErrors (Sandbox service interface) ───────── +/** Thrown when a terminal operation (spawn, write, resize) fails. */ export class TerminalError extends Schema.TaggedError()( "TerminalError", {cause: Schema.Defect}, ) {} +/** Thrown when a sandbox-level operation (create/get/delete session, destroy) fails. */ export class SandboxError extends Schema.TaggedError()( "SandboxError", {cause: Schema.Defect}, ) {} +/** Thrown when command execution fails. `command` is the shell string that was run. */ export class ExecError extends Schema.TaggedError()( "ExecError", {command: Schema.String, cause: Schema.Defect}, ) {} +/** Thrown when a filesystem operation fails. `operation` is the verb (e.g. "read", "write", "mkdir"). */ export class FileSystemError extends Schema.TaggedError()( "FileSystemError", {path: Schema.String, operation: Schema.String, cause: Schema.Defect}, ) {} -// ── New errors (plain TS) ─────────────────────────────────── -// Used by Wormhole channel/session management +// ── Plain TS errors (Wormhole channel/session management) ─── +/** Thrown when all 254 data channels are in use and a new PTY cannot be assigned. */ export class ChannelExhaustedError extends Error { readonly _tag = "ChannelExhaustedError"; constructor() { @@ -34,6 +49,7 @@ export class ChannelExhaustedError extends Error { } } +/** Thrown when a sandbox has been evicted or hibernated and is no longer reachable. */ export class SandboxSleepError extends Error { readonly _tag = "SandboxSleepError"; readonly sessionId: string; @@ -43,6 +59,7 @@ export class SandboxSleepError extends Error { } } +/** Thrown when referencing a session ID that doesn't exist in the Wormhole state. */ export class SessionNotFoundError extends Error { readonly _tag = "SessionNotFoundError"; readonly sessionId: string; @@ -52,6 +69,7 @@ export class SessionNotFoundError extends Error { } } +/** Thrown when referencing a tab ID that doesn't exist in the Wormhole state. */ export class TabNotFoundError extends Error { readonly _tag = "TabNotFoundError"; readonly tabId: string; diff --git a/packages/sandbox/src/Protocol.ts b/packages/sandbox/src/Protocol.ts index 91f31e0..72436be 100644 --- a/packages/sandbox/src/Protocol.ts +++ b/packages/sandbox/src/Protocol.ts @@ -16,7 +16,7 @@ export const CONTROL_CHANNEL = 255; // --- Binary framing --- -/** @since 0.0.2 @category binary */ +/** Encode a binary frame: `[channel (1 byte)][payload (rest)]`. @since 0.0.2 @category binary */ export const encodeBinaryFrame = (channel: number, payload: Uint8Array): Uint8Array => { const frame = new Uint8Array(1 + payload.byteLength); frame[0] = channel; @@ -24,7 +24,7 @@ export const encodeBinaryFrame = (channel: number, payload: Uint8Array): Uint8Ar return frame; }; -/** @since 0.0.2 @category binary */ +/** Parse a binary frame back into channel number and payload. Inverse of {@link encodeBinaryFrame}. @since 0.0.2 @category binary */ export const parseBinaryFrame = (frame: Uint8Array): {channel: number; payload: Uint8Array} => ({ channel: frame[0], payload: frame.subarray(1), @@ -32,59 +32,59 @@ export const parseBinaryFrame = (frame: Uint8Array): {channel: number; payload: // --- Client → Server messages --- -/** @since 0.1.0 @category models */ +/** Client → Server. Sent on WebSocket open to initialize the session with terminal dimensions. @since 0.1.0 @category models */ export class ConnectMessage extends S.Class("ConnectMessage")({ type: S.Literal("connect"), width: S.Number, height: S.Number, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Create a new named session. @since 0.1.0 @category models */ export class SessionCreateMessage extends S.Class("SessionCreateMessage")({ type: S.Literal("session_create"), name: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Destroy a session and all its tabs/panes. @since 0.1.0 @category models */ export class SessionDestroyMessage extends S.Class("SessionDestroyMessage")({ type: S.Literal("session_destroy"), sessionId: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Rename an existing session. @since 0.1.0 @category models */ export class SessionRenameMessage extends S.Class("SessionRenameMessage")({ type: S.Literal("session_rename"), sessionId: S.String, name: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Create a new tab within a session. @since 0.1.0 @category models */ export class TabCreateMessage extends S.Class("TabCreateMessage")({ type: S.Literal("tab_create"), sessionId: S.String, name: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Close a tab and release its pane channels. @since 0.1.0 @category models */ export class TabCloseMessage extends S.Class("TabCloseMessage")({ type: S.Literal("tab_close"), tabId: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Rename a tab. @since 0.1.0 @category models */ export class TabRenameMessage extends S.Class("TabRenameMessage")({ type: S.Literal("tab_rename"), tabId: S.String, name: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Switch the active tab. @since 0.1.0 @category models */ export class TabSwitchMessage extends S.Class("TabSwitchMessage")({ type: S.Literal("tab_switch"), tabId: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Split a pane. `orientation` uses user-facing semantics ("horizontal" = side-by-side). @since 0.1.0 @category models */ export class PaneSplitMessage extends S.Class("PaneSplitMessage")({ type: S.Literal("pane_split"), paneId: S.String, @@ -93,13 +93,13 @@ export class PaneSplitMessage extends S.Class("PaneSplitMessag rows: S.Number, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Close a pane and release its channel. @since 0.1.0 @category models */ export class PaneCloseMessage extends S.Class("PaneCloseMessage")({ type: S.Literal("pane_close"), paneId: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Notify the server that a pane was resized. @since 0.1.0 @category models */ export class PaneResizeMessage extends S.Class("PaneResizeMessage")({ type: S.Literal("pane_resize"), paneId: S.String, @@ -107,13 +107,13 @@ export class PaneResizeMessage extends S.Class("PaneResizeMes rows: S.Number, }) {} -/** @since 0.1.0 @category models */ +/** Client → Server. Move focus to an adjacent pane in the given direction. @since 0.1.0 @category models */ export class PaneFocusMessage extends S.Class("PaneFocusMessage")({ type: S.Literal("pane_focus"), direction: S.Union(S.Literal("left"), S.Literal("right"), S.Literal("up"), S.Literal("down")), }) {} -/** @since 0.1.0 @category models */ +/** Union of all client → server control messages. @since 0.1.0 @category models */ export const ClientMessage = S.Union( ConnectMessage, SessionCreateMessage, @@ -134,7 +134,7 @@ export type ClientMessage = S.Schema.Type; // --- Server → Client messages --- -/** @since 0.1.0 @category models */ +/** Schema for a session as seen by the client. @since 0.1.0 @category models */ export const SessionRecord = S.Struct({ id: S.String, sandboxId: S.String, @@ -142,7 +142,7 @@ export const SessionRecord = S.Struct({ createdAt: S.Number, }); -/** @since 0.1.0 @category models */ +/** Schema for a tab as seen by the client. `layout` is the serialized `@usirin/layout-tree` tree, `focus` is a `StackPath`. @since 0.1.0 @category models */ export const TabRecord = S.Struct({ id: S.String, sessionId: S.String, @@ -151,7 +151,7 @@ export const TabRecord = S.Struct({ focus: S.Array(S.Number), }); -/** @since 0.1.0 @category models */ +/** Server → Client. Full state snapshot sent on initial connect. @since 0.1.0 @category models */ export class StateMessage extends S.Class("StateMessage")({ type: S.Literal("state"), sessions: S.Array(SessionRecord), @@ -161,7 +161,7 @@ export class StateMessage extends S.Class("StateMessage")({ connected: S.Record({key: S.String, value: S.Boolean}), }) {} -/** @since 0.1.0 @category models */ +/** Server → Client. Incremental update after a tab/pane mutation. @since 0.1.0 @category models */ export class LayoutUpdateMessage extends S.Class("LayoutUpdateMessage")({ type: S.Literal("layout_update"), tabs: S.Array(TabRecord), @@ -170,13 +170,13 @@ export class LayoutUpdateMessage extends S.Class("LayoutUpd connected: S.Record({key: S.String, value: S.Boolean}), }) {} -/** @since 0.1.0 @category models */ +/** Server → Client. Signals that a session's sandbox was reset (e.g. after sleep). @since 0.1.0 @category models */ export class SessionsResetMessage extends S.Class("SessionsResetMessage")({ type: S.Literal("sessions_reset"), sessionId: S.String, }) {} -/** @since 0.1.0 @category models */ +/** Union of all server → client control messages. @since 0.1.0 @category models */ export const ServerMessage = S.Union(StateMessage, LayoutUpdateMessage, SessionsResetMessage); /** @since 0.1.0 @category models */ @@ -184,14 +184,14 @@ export type ServerMessage = S.Schema.Type; // --- Helpers --- -/** @since 0.1.0 @category helpers */ +/** JSON-encode a server message and wrap it in a control-channel binary frame. @since 0.1.0 @category helpers */ export function encodeControlMessage(msg: ServerMessage): Uint8Array { const json = JSON.stringify(msg); const payload = new TextEncoder().encode(json); return encodeBinaryFrame(CONTROL_CHANNEL, payload); } -/** @since 0.1.0 @category helpers */ +/** Decode a control-channel payload (already stripped of the channel byte) into a typed `ClientMessage`. Throws on invalid JSON or schema mismatch. @since 0.1.0 @category helpers */ export function decodeControlMessage(payload: Uint8Array): ClientMessage { const json = JSON.parse(new TextDecoder().decode(payload)); return S.decodeUnknownSync(ClientMessage)(json); diff --git a/packages/sandbox/src/Sandbox.ts b/packages/sandbox/src/Sandbox.ts index c278072..ed3c552 100644 --- a/packages/sandbox/src/Sandbox.ts +++ b/packages/sandbox/src/Sandbox.ts @@ -1,18 +1,28 @@ +/** + * @module + * + * Pure interface definitions for the Sandbox service. `Sandbox` is an + * Effect {@link Context.Tag} — consumers depend on the tag, and the + * concrete implementation (e.g. Cloudflare Sandbox SDK) is provided at the edge. + */ import {Context, type Effect, type Stream} from "effect"; // ── Options ──────────────────────────────────────────────── +/** Options for creating a new sandbox session. */ export interface SessionOptions { readonly id?: string; readonly env?: Record; readonly cwd?: string; } +/** Initial dimensions for a terminal. */ export interface TerminalOptions { readonly cols: number; readonly rows: number; } +/** Options for one-shot command execution (`exec` / `execStream`). */ export interface ExecOptions { readonly env?: Record; readonly cwd?: string; @@ -20,6 +30,7 @@ export interface ExecOptions { readonly stdin?: string; } +/** Options for long-running background processes (`startProcess`). */ export interface ProcessOptions { readonly processId?: string; readonly cwd?: string; @@ -28,6 +39,7 @@ export interface ProcessOptions { // ── Result types ─────────────────────────────────────────── +/** Result of a completed one-shot command execution. */ export interface ExecResult { readonly success: boolean; readonly stdout: string; @@ -35,12 +47,14 @@ export interface ExecResult { readonly exitCode: number; } +/** Streaming event emitted during `execStream`. Events arrive in order: `start`, then interleaved `stdout`/`stderr`, then `complete` or `error`. */ export interface ExecEvent { readonly type: "start" | "stdout" | "stderr" | "complete" | "error"; readonly data?: string; readonly exitCode?: number; } +/** Handle to a long-running background process started via `startProcess`. */ export interface ProcessHandle { readonly id: string; readonly kill: () => Effect.Effect; @@ -48,14 +62,16 @@ export interface ProcessHandle { readonly waitForExit: () => Effect.Effect; } +/** Snapshot of a background process's current state. */ export interface ProcessInfo { readonly id: string; readonly command: string; readonly running: boolean; } -// ── Terminal (replaces PtyProcess) ───────────────────────── +// ── Terminal ───────────────────────────────────────────── +/** An interactive terminal (PTY). `output` is a stream of raw terminal data; `write` sends keystrokes/data in. */ export interface Terminal { readonly output: Stream.Stream; readonly awaitExit: Effect.Effect; @@ -65,6 +81,14 @@ export interface Terminal { // ── Session (one isolated execution context) ─────────────── +/** + * One isolated execution context within a sandbox. + * + * - `terminal` — spawn an interactive PTY (for UI terminals) + * - `exec` — run a command and collect stdout/stderr (one-shot, buffered) + * - `execStream` — run a command and stream events as they arrive + * - `startProcess` — launch a long-running background process with a handle to kill/wait + */ export interface Session { readonly id: string; readonly terminal: ( @@ -102,6 +126,12 @@ export interface Session { // ── Sandbox (the platform primitive) ─────────────────────── +/** + * Effect context tag for the sandbox service. + * + * Provides session lifecycle management: create, get, delete, and destroy. + * The concrete implementation is swapped at the composition root (e.g. Cloudflare Sandbox SDK). + */ export class Sandbox extends Context.Tag("@kampus/sandbox/Sandbox")< Sandbox, { diff --git a/packages/sandbox/src/TabbedLayout.ts b/packages/sandbox/src/TabbedLayout.ts index c7df1ec..7c33af1 100644 --- a/packages/sandbox/src/TabbedLayout.ts +++ b/packages/sandbox/src/TabbedLayout.ts @@ -1,7 +1,18 @@ +/** + * @module + * + * Immutable, tmux-like layout model built on `@usirin/layout-tree`. + * Manages a list of tabs, each containing a split-pane tree with per-tab focus tracking. + * + * **Orientation note:** This module uses user-facing orientation semantics + * ("horizontal" = side-by-side), which is the *inverse* of the layout-tree library's + * convention. The conversion happens in {@link toLibraryOrientation}. + */ import * as LT from "@usirin/layout-tree"; // --- Types --- +/** A single tab: a named pane tree with independent focus state. */ export interface Tab { id: string; name: string; @@ -9,6 +20,7 @@ export interface Tab { focus: LT.StackPath; } +/** Top-level layout state: an ordered list of tabs with one active. */ export interface TabbedLayout { tabs: Tab[]; activeTab: number; @@ -35,10 +47,12 @@ function toLibraryOrientation(orientation: LT.Orientation): LT.Orientation { return orientation === "horizontal" ? "vertical" : "horizontal"; } +/** Return the currently active tab. */ export function getActiveTab(layout: TabbedLayout): Tab { return layout.tabs[layout.activeTab]; } +/** Return the focused window (pane) in the active tab, or `null` if focus points at a stack. */ export function getFocusedWindow(layout: TabbedLayout): LT.Window | null { const tab = getActiveTab(layout); const node = LT.getAt(tab.tree.root, tab.focus); @@ -46,6 +60,7 @@ export function getFocusedWindow(layout: TabbedLayout): LT.Window | null { return null; } +/** Collect all window keys across every tab. Useful for mapping pane IDs to channels. */ export function allWindowKeys(layout: TabbedLayout): string[] { const keys: string[] = []; function walk(node: LT.Window | LT.Stack) { @@ -63,6 +78,7 @@ export function allWindowKeys(layout: TabbedLayout): string[] { // --- Tab operations --- +/** Create a fresh layout with a single tab containing one pane. */ export function createTabbedLayout( tabName: string, windowKey: string, @@ -75,6 +91,7 @@ export function createTabbedLayout( }; } +/** Append a new tab with a single pane and make it active. */ export function createTab( layout: TabbedLayout, name: string, @@ -89,6 +106,7 @@ export function createTab( }; } +/** Close a tab by index. Returns `null` if this is the last tab (can't close it). */ export function closeTab( layout: TabbedLayout, tabIndex: number, @@ -104,6 +122,7 @@ export function closeTab( return { tabs, activeTab }; } +/** Switch to a different tab by index. */ export function switchTab( layout: TabbedLayout, tabIndex: number, @@ -111,6 +130,7 @@ export function switchTab( return { ...layout, activeTab: tabIndex }; } +/** Rename a tab by index. */ export function renameTab( layout: TabbedLayout, tabIndex: number, @@ -124,6 +144,7 @@ export function renameTab( // --- Pane operations (scoped to active tab) --- +/** Split a pane in the active tab. Returns the updated layout and the `StackPath` of the new pane. Focus moves to the new pane. */ export function splitPane( layout: TabbedLayout, paneId: string, @@ -167,6 +188,7 @@ export function splitPane( return { layout: { ...layout, tabs }, newPath }; } +/** Close a pane in the active tab. Returns `null` if the tab becomes empty. Focus moves to the nearest sibling. */ export function closePane( layout: TabbedLayout, path: LT.StackPath, @@ -210,6 +232,7 @@ export function closePane( return { ...layout, tabs }; } +/** Move focus to an adjacent pane in the given direction. No-op if there is no neighbor. */ export function moveFocus( layout: TabbedLayout, direction: LT.Direction, diff --git a/packages/sandbox/test/ChannelMap.test.ts b/packages/sandbox/test/ChannelMap.test.ts index 49afee6..cbab86c 100644 --- a/packages/sandbox/test/ChannelMap.test.ts +++ b/packages/sandbox/test/ChannelMap.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect} from "vitest"; +import {describe, expect, it} from "vitest"; import {ChannelMap} from "../src/ChannelMap.ts"; describe("ChannelMap", () => {