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 | 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 |
| 2026-02-14 | DO authoritative over layout with per-tab focus |
Expand All @@ -23,6 +24,20 @@ For lightweight decisions, a single statement suffices:

For significant decisions:

## [2026-02-15-180300] Track pane dimensions in paneSizes Map

**Status**: Accepted

**Context**: reconnectTerminal needed actual cols/rows but layout tree does not store them and handlePaneResize only forwards to terminal WS without persisting

**Decision**: Track pane dimensions in paneSizes Map

**Rationale**: In-memory Map is simplest — lost on hibernation but so are terminal WSes. Persisting to storage is unnecessary since reconnect already creates fresh PTYs. Alternative: send dimensions from frontend on reconnect — adds protocol complexity for no gain.

**Consequences**: paneSizes must be updated at all 4 createTerminalWs call sites. Falls back to 80x24 for panes created before tracking or after hibernation.

---

## [2026-02-14-233233] Server-side output buffer in DO for reconnect replay

**Status**: Accepted
Expand Down
11 changes: 11 additions & 0 deletions .context/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<!-- INDEX:START -->
| Date | Learning |
|------|--------|
| 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 |
| 2026-02-14 | ghostty-web container element must have no children |
Expand All @@ -12,6 +13,16 @@
| 2026-02-14 | Zensical explicit nav is full override |
<!-- INDEX:END -->

## [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

**Lesson**: Any code path that calls createTerminalWs should also call paneSizes.set(ptyId, {cols, rows}). There are 4 sites: handleSessionCreate, handleTabCreate, handlePaneSplit, and reconnectTerminal (which looks up existing size).

**Application**: When adding new terminal creation paths, always pair with paneSizes.set. Grep for createTerminalWs to find all call sites.

---

## [2026-02-14-233231] DO non-hibernation reconnect needs server-side replay

**Context**: Page reload doesn't hibernate the DO. reconnectAllTerminals() checks this.terminals.has() and skips. Frontend gets blank terminals because no ring buffer replay happens from CF Sandbox.
Expand Down
4 changes: 3 additions & 1 deletion .context/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ STRUCTURE RULES (see CONSTITUTION.md):
### Phase 1: [Name] `#priority:high`
- [ ] Clean up @kampus/wormhole package (old, no longer imported by live code) #added:2026-02-14-222749

- [ ] Handle stale sessions on reconnect — detect dead terminal WSes and show session expired state instead of blank pane #added:2026-02-14-222749
- [ ] Add reconnect retry limit — track per-ptyId attempt count, fail permanently after N attempts to prevent unbounded reconnect loops when sandbox is permanently dead #priority:medium #added:2026-02-15

- [x] Handle stale sessions on reconnect — detect dead terminal WSes and show session expired state instead of blank pane #added:2026-02-14-222749 #done:2026-02-15

- [x] Implement tab + focus wrapper on top of @usirin/layout-tree #priority:medium #added:2026-02-14-200550 #done:2026-02-14

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,6 @@ ralph*.sh

# Zensical build output
site/

# Git worktrees
.worktrees/
20 changes: 13 additions & 7 deletions apps/kamp-us/src/wormhole/PaneLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function PaneLayout() {
}}
>
<Group orientation={tree.root.orientation}>
{renderChildren(tree.root, [], tab.focus, state.channels)}
{renderChildren(tree.root, [], tab.focus, state.channels, state.paneConnected)}
</Group>
</div>
);
Expand All @@ -41,22 +41,25 @@ function renderChildren(
path: LT.StackPath,
focus: number[],
channels: Record<string, number>,
paneConnected: Record<string, boolean>,
) {
return stack.children.map((child, i) => {
const childPath = [...path, i];
return (
<Fragment key={child.id}>
{i > 0 && (
<Separator
className={stack.orientation === "horizontal" ? styles.resizeHandleH : styles.resizeHandleV}
/>
)}
<Separator
className={
stack.orientation === "horizontal" ? styles.resizeHandleH : styles.resizeHandleV
}
/>
)}
<Panel>
{child.tag === "window" ? (
renderWindow(child as LT.Window, childPath, focus, channels)
renderWindow(child as LT.Window, childPath, focus, channels, paneConnected)
) : (
<Group orientation={(child as LT.Stack).orientation}>
{renderChildren(child as LT.Stack, childPath, focus, channels)}
{renderChildren(child as LT.Stack, childPath, focus, channels, paneConnected)}
</Group>
)}
</Panel>
Expand All @@ -70,17 +73,20 @@ function renderWindow(
path: LT.StackPath,
focus: number[],
channels: Record<string, number>,
paneConnected: Record<string, boolean>,
) {
const channel = channels[window.key];
if (channel === undefined) return <div>Loading...</div>;

const isFocused = JSON.stringify(path) === JSON.stringify(focus);
const isConnected = paneConnected[window.key] ?? true;

return (
<TerminalPane
channel={channel}
sessionId={window.key}
focused={isFocused}
connected={isConnected}
onFocus={() => {
/* focus is managed by DO */
}}
Expand Down
17 changes: 15 additions & 2 deletions apps/kamp-us/src/wormhole/TerminalPane.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import type {ITheme} from "ghostty-web";
import {useChannelTerminal} from "./use-channel-terminal.ts";
import {useMux} from "./MuxClient.tsx";
import {useChannelTerminal} from "./use-channel-terminal.ts";
import styles from "./WormholeLayout.module.css";

interface TerminalPaneProps {
channel: number;
sessionId: string;
focused: boolean;
connected: boolean;
onFocus: () => void;
theme?: ITheme;
}

export function TerminalPane({channel, sessionId, focused, onFocus, theme}: TerminalPaneProps) {
export function TerminalPane({
channel,
sessionId,
focused,
connected,
onFocus,
theme,
}: TerminalPaneProps) {
const {ref} = useChannelTerminal({channel, sessionId, theme});
const {splitPane, closePane} = useMux();

Expand All @@ -20,6 +28,11 @@ export function TerminalPane({channel, sessionId, focused, onFocus, theme}: Term
// biome-ignore lint/a11y/noStaticElementInteractions: terminal container, not a button
<div className={styles.pane} data-focused={focused || undefined} onClick={onFocus}>
<div ref={ref} style={{flex: 1, minHeight: 0}} />
{!connected && (
<div className={styles.disconnectedOverlay}>
<span>Disconnected — press any key to reconnect</span>
</div>
)}
<div className={styles.paneControls}>
<button
type="button"
Expand Down
16 changes: 16 additions & 0 deletions apps/kamp-us/src/wormhole/WormholeLayout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,19 @@
.resizeHandleV {
height: 2px;
}

.disconnectedOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 5;
}

.disconnectedOverlay span {
color: #888;
font-size: 13px;
font-family: monospace;
}
61 changes: 33 additions & 28 deletions apps/kamp-us/src/wormhole/use-wormhole-client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// apps/kamp-us/src/wormhole/use-wormhole-client.ts
import {useCallback, useEffect, useRef, useState} from "react";

import {
type ClientMessage,
CONTROL_CHANNEL,
parseBinaryFrame,
encodeBinaryFrame,
parseBinaryFrame,
type ServerMessage,
type ClientMessage,
} from "@kampus/sandbox/Protocol";
import {useCallback, useEffect, useRef, useState} from "react";

interface SessionRecord {
id: string;
Expand All @@ -28,6 +29,7 @@ interface WormholeClientState {
tabs: TabRecord[];
activeTab: string | null;
channels: Record<string, number>;
paneConnected: Record<string, boolean>;
connected: boolean;
}

Expand All @@ -41,7 +43,12 @@ interface WormholeClient {
closeTab: (tabId: string) => void;
switchTab: (tabId: string) => void;
renameTab: (tabId: string, name: string) => void;
splitPane: (paneId: string, orientation: "horizontal" | "vertical", cols: number, rows: number) => void;
splitPane: (
paneId: string,
orientation: "horizontal" | "vertical",
cols: number,
rows: number,
) => void;
closePane: (paneId: string) => void;
resizePane: (paneId: string, cols: number, rows: number) => void;
moveFocus: (direction: "left" | "right" | "up" | "down") => void;
Expand All @@ -60,6 +67,7 @@ export function useWormholeClient(
tabs: [],
activeTab: null,
channels: {},
paneConnected: {},
connected: false,
});

Expand Down Expand Up @@ -114,8 +122,8 @@ export function useWormholeClient(
return () => {
ws.close();
};
// sendControl is stable (empty deps). viewport intentionally excluded: send initial dimensions on connect, not reconnect on resize.
// eslint-disable-next-line react-hooks/exhaustive-deps
// sendControl is stable (empty deps). viewport intentionally excluded: send initial dimensions on connect, not reconnect on resize.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);

function handleServerMessage(msg: ServerMessage) {
Expand All @@ -127,6 +135,7 @@ export function useWormholeClient(
tabs: msg.tabs as TabRecord[],
activeTab: msg.activeTab,
channels: msg.channels as Record<string, number>,
paneConnected: msg.connected as Record<string, boolean>,
}));
break;
case "layout_update":
Expand All @@ -135,36 +144,32 @@ export function useWormholeClient(
tabs: msg.tabs as TabRecord[],
activeTab: msg.activeTab,
channels: msg.channels as Record<string, number>,
paneConnected: msg.connected as Record<string, boolean>,
}));
break;
case "session_exit":
break;
case "sessions_reset":
break;
}
}

const onTerminalData = useCallback(
(channel: number, callback: (data: Uint8Array) => void) => {
if (!terminalListeners.current.has(channel)) {
terminalListeners.current.set(channel, new Set());
}
// biome-ignore lint/style/noNonNullAssertion: has() check above guarantees entry
terminalListeners.current.get(channel)!.add(callback);

// Flush any data that arrived before this listener mounted
const buffered = terminalBuffers.current.get(channel);
if (buffered) {
for (const data of buffered) callback(data);
terminalBuffers.current.delete(channel);
}
const onTerminalData = useCallback((channel: number, callback: (data: Uint8Array) => void) => {
if (!terminalListeners.current.has(channel)) {
terminalListeners.current.set(channel, new Set());
}
// biome-ignore lint/style/noNonNullAssertion: has() check above guarantees entry
terminalListeners.current.get(channel)!.add(callback);

// Flush any data that arrived before this listener mounted
const buffered = terminalBuffers.current.get(channel);
if (buffered) {
for (const data of buffered) callback(data);
terminalBuffers.current.delete(channel);
}

return () => {
terminalListeners.current.get(channel)?.delete(callback);
};
},
[],
);
return () => {
terminalListeners.current.get(channel)?.delete(callback);
};
}, []);

return {
state,
Expand Down
Loading