Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .context/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<!-- INDEX:START -->
| 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 |
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions .context/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<!-- INDEX:START -->
| 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 |
Expand All @@ -13,6 +16,36 @@
| 2026-02-14 | Zensical explicit nav is full override |
<!-- INDEX:END -->

## [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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

3 changes: 3 additions & 0 deletions apps/kamp-us/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kamp.us</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
</head>
<body class="dark">
<div id="root"></div>
Expand Down
138 changes: 138 additions & 0 deletions apps/kamp-us/src/wormhole/ChromeBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.chromeBar}>
{/* ── Session Selector (left zone) ── */}
<div className={styles.sessionSelector}>
<span className={styles.sessionLabel}>wormhole</span>
<Menu.Root>
<Menu.Trigger className={styles.sessionTrigger}>
<span>{activeSession?.name ?? "—"}</span>
<span className={styles.chevron} />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner
className={styles.sessionPositioner}
side="bottom"
align="start"
sideOffset={1}
>
<Menu.Popup className={styles.sessionPopup}>
{state.sessions.map((session) => {
const firstTab = state.tabs.find((t) => t.sessionId === session.id);
return (
<Menu.Item
key={session.id}
className={styles.sessionItem}
data-active={session.id === activeSessionId || undefined}
onClick={() => {
if (firstTab) switchTab(firstTab.id);
}}
>
<span>{session.name}</span>
<button
type="button"
className={styles.closeIcon}
aria-label={`Destroy ${session.name}`}
onClick={(e) => {
e.stopPropagation();
destroySession(session.id);
}}
>
<CloseIconSvg />
</button>
</Menu.Item>
);
})}
<Menu.Separator className={styles.sessionDivider} />
<Menu.Item
className={styles.sessionAction}
onClick={() => createSession(`session-${state.sessions.length + 1}`)}
>
+ New Session
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>

{/* ── Tab Bar (middle zone) ── */}
<Tabs.Root
className={styles.tabsRoot}
value={state.activeTab}
onValueChange={(value) => switchTab(value as string)}
>
<Tabs.List className={styles.tabList}>
{visibleTabs.map((tab) => (
<Tabs.Tab key={tab.id} value={tab.id} className={styles.tabItem}>
<span>{tab.name}</span>
<button
type="button"
className={styles.closeIcon}
aria-label={`Close ${tab.name}`}
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
>
<CloseIconSvg />
</button>
</Tabs.Tab>
))}
{activeSessionId && (
<button
type="button"
className={styles.tabAdd}
onClick={() => createTab(activeSessionId, `tab-${visibleTabs.length + 1}`)}
aria-label="New tab"
>
+
</button>
)}
</Tabs.List>
</Tabs.Root>

{/* ── Status Dot (right zone) ── */}
<div className={styles.chromeStatus}>
<div
className={styles.statusDot}
title={state.connected ? "Connected" : "Disconnected"}
data-disconnected={!state.connected || undefined}
/>
</div>
</div>
);
}

function CloseIconSvg() {
return (
<svg
aria-hidden="true"
viewBox="0 0 8 8"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="1" y1="1" x2="7" y2="7" />
<line x1="7" y1="1" x2="1" y2="7" />
</svg>
);
}
19 changes: 12 additions & 7 deletions apps/kamp-us/src/wormhole/MuxClient.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useWormholeClient>;
Expand All @@ -24,14 +23,20 @@ export function MuxClient({url, viewport}: MuxClientProps) {
const client = useWormholeClient(url, viewport);

if (!client.state.connected) {
return <div className={styles.container}>Connecting...</div>;
return (
<div className={styles.container} data-wormhole>
<div className={styles.connecting}>
<div className={styles.connectingSpinner} />
<span className={styles.connectingText}>Connecting...</span>
</div>
</div>
);
}

return (
<MuxContext.Provider value={client}>
<div className={styles.container}>
<SessionBar />
<TabBar />
<div className={styles.container} data-wormhole>
<ChromeBar />
<PaneLayout />
</div>
</MuxContext.Provider>
Expand Down
30 changes: 22 additions & 8 deletions apps/kamp-us/src/wormhole/PaneLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div style={{flex: 1, position: "relative"}}>
<div className={styles.paneArea}>
{state.tabs.map((tab) => {
const tree = tab.layout as LT.Tree;
if (!tree || !tree.root) return null;
Expand All @@ -27,7 +27,14 @@ export function PaneLayout() {
}}
>
<Group orientation={tree.root.orientation}>
{renderChildren(tree.root, [], tab.focus, state.channels, state.paneConnected)}
{renderChildren(
tree.root,
[],
tab.focus,
state.channels,
state.paneConnected,
focusPane,
)}
</Group>
</div>
);
Expand All @@ -42,6 +49,7 @@ function renderChildren(
focus: number[],
channels: Record<string, number>,
paneConnected: Record<string, boolean>,
focusPane: (path: number[]) => void,
) {
return stack.children.map((child, i) => {
const childPath = [...path, i];
Expand All @@ -56,10 +64,17 @@ function renderChildren(
)}
<Panel>
{child.tag === "window" ? (
renderWindow(child as LT.Window, childPath, focus, channels, paneConnected)
renderWindow(child as LT.Window, childPath, focus, channels, paneConnected, focusPane)
) : (
<Group orientation={(child as LT.Stack).orientation}>
{renderChildren(child as LT.Stack, childPath, focus, channels, paneConnected)}
{renderChildren(
child as LT.Stack,
childPath,
focus,
channels,
paneConnected,
focusPane,
)}
</Group>
)}
</Panel>
Expand All @@ -74,6 +89,7 @@ function renderWindow(
focus: number[],
channels: Record<string, number>,
paneConnected: Record<string, boolean>,
focusPane: (path: number[]) => void,
) {
const channel = channels[window.key];
if (channel === undefined) return <div>Loading...</div>;
Expand All @@ -87,9 +103,7 @@ function renderWindow(
sessionId={window.key}
focused={isFocused}
connected={isConnected}
onFocus={() => {
/* focus is managed by DO */
}}
onFocus={() => focusPane(path)}
/>
);
}
Loading