diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index 1a6db1f..318e4cb 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -3,6 +3,7 @@ | 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 | @@ -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 diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 400b032..7401c47 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -3,6 +3,7 @@ | 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 | @@ -12,6 +13,16 @@ | 2026-02-14 | Zensical explicit nav is full override | +## [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. diff --git a/.context/TASKS.md b/.context/TASKS.md index d572277..1da6135 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -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 diff --git a/.gitignore b/.gitignore index 5898ea5..a5d8295 100644 --- a/.gitignore +++ b/.gitignore @@ -202,3 +202,6 @@ ralph*.sh # Zensical build output site/ + +# Git worktrees +.worktrees/ diff --git a/apps/kamp-us/src/wormhole/PaneLayout.tsx b/apps/kamp-us/src/wormhole/PaneLayout.tsx index 2ee701c..aa76b9b 100644 --- a/apps/kamp-us/src/wormhole/PaneLayout.tsx +++ b/apps/kamp-us/src/wormhole/PaneLayout.tsx @@ -27,7 +27,7 @@ export function PaneLayout() { }} > - {renderChildren(tree.root, [], tab.focus, state.channels)} + {renderChildren(tree.root, [], tab.focus, state.channels, state.paneConnected)} ); @@ -41,22 +41,25 @@ function renderChildren( path: LT.StackPath, focus: number[], channels: Record, + paneConnected: Record, ) { return stack.children.map((child, i) => { const childPath = [...path, i]; return ( {i > 0 && ( - - )} + + )} {child.tag === "window" ? ( - renderWindow(child as LT.Window, childPath, focus, channels) + renderWindow(child as LT.Window, childPath, focus, channels, paneConnected) ) : ( - {renderChildren(child as LT.Stack, childPath, focus, channels)} + {renderChildren(child as LT.Stack, childPath, focus, channels, paneConnected)} )} @@ -70,17 +73,20 @@ function renderWindow( path: LT.StackPath, focus: number[], channels: Record, + paneConnected: Record, ) { const channel = channels[window.key]; if (channel === undefined) return
Loading...
; const isFocused = JSON.stringify(path) === JSON.stringify(focus); + const isConnected = paneConnected[window.key] ?? true; return ( { /* focus is managed by DO */ }} diff --git a/apps/kamp-us/src/wormhole/TerminalPane.tsx b/apps/kamp-us/src/wormhole/TerminalPane.tsx index 3c4d205..d2127ce 100644 --- a/apps/kamp-us/src/wormhole/TerminalPane.tsx +++ b/apps/kamp-us/src/wormhole/TerminalPane.tsx @@ -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(); @@ -20,6 +28,11 @@ export function TerminalPane({channel, sessionId, focused, onFocus, theme}: Term // biome-ignore lint/a11y/noStaticElementInteractions: terminal container, not a button
+ {!connected && ( +
+ Disconnected — press any key to reconnect +
+ )}
+ + +
+
+ ); +} +``` + +**Step 2: Add overlay CSS** + +Append to `WormholeLayout.module.css`: + +```css +.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; +} +``` + +**Step 3: Typecheck all** + +Run: `turbo run typecheck` + +Expected: PASS across all packages. + +**Step 4: Commit** + +``` +feat(sandbox): show disconnected overlay on stale terminal panes +``` + +--- + +### Task 8: Final verification + +**Step 1: Full typecheck** + +Run: `turbo run typecheck` + +Expected: PASS + +**Step 2: Biome lint** + +Run: `biome check --write apps/worker/src/features/sandbox/WormholeServer.ts apps/kamp-us/src/wormhole/use-wormhole-client.ts apps/kamp-us/src/wormhole/PaneLayout.tsx apps/kamp-us/src/wormhole/TerminalPane.tsx apps/kamp-us/src/wormhole/WormholeLayout.module.css packages/sandbox/src/Protocol.ts` + +Expected: PASS or auto-fix + +**Step 3: Update design doc** + +Update `docs/plans/2026-02-15-stale-session-design.md` to reflect final decision: channels are NOT released on disconnect (design doc currently says "Release the channel"). Fix the Data Flow section too. + +**Step 4: Commit** + +``` +docs: update stale session design to match implementation +``` diff --git a/packages/sandbox/src/Protocol.ts b/packages/sandbox/src/Protocol.ts index aee589b..91f31e0 100644 --- a/packages/sandbox/src/Protocol.ts +++ b/packages/sandbox/src/Protocol.ts @@ -158,6 +158,7 @@ export class StateMessage extends S.Class("StateMessage")({ tabs: S.Array(TabRecord), activeTab: S.NullOr(S.String), channels: S.Record({key: S.String, value: S.Number}), + connected: S.Record({key: S.String, value: S.Boolean}), }) {} /** @since 0.1.0 @category models */ @@ -166,15 +167,7 @@ export class LayoutUpdateMessage extends S.Class("LayoutUpd tabs: S.Array(TabRecord), activeTab: S.NullOr(S.String), channels: S.Record({key: S.String, value: S.Number}), -}) {} - -/** @since 0.1.0 @category models */ -export class SessionExitMessage extends S.Class("SessionExitMessage")({ - type: S.Literal("session_exit"), - sessionId: S.String, - ptyId: S.String, - channel: S.Number, - exitCode: S.Number, + connected: S.Record({key: S.String, value: S.Boolean}), }) {} /** @since 0.1.0 @category models */ @@ -184,12 +177,7 @@ export class SessionsResetMessage extends S.Class("Session }) {} /** @since 0.1.0 @category models */ -export const ServerMessage = S.Union( - StateMessage, - LayoutUpdateMessage, - SessionExitMessage, - SessionsResetMessage, -); +export const ServerMessage = S.Union(StateMessage, LayoutUpdateMessage, SessionsResetMessage); /** @since 0.1.0 @category models */ export type ServerMessage = S.Schema.Type;