Skip to content
Open
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
53 changes: 13 additions & 40 deletions src/web/client/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import type { PTYSessionInfo } from 'opencode-pty/web/shared/types'

import { useWebSocket } from '../hooks/use-web-socket.ts'
Expand All @@ -11,40 +11,24 @@ import { api } from '../../shared/api-client.ts'
export function App() {
const [sessions, setSessions] = useState<PTYSessionInfo[]>([])
const [activeSession, setActiveSession] = useState<PTYSessionInfo | null>(null)
const [rawOutput, setRawOutput] = useState<string>('')
const terminalRef = useRef<RawTerminal | null>(null)

const [connected, setConnected] = useState(false)
const [wsMessageCount, setWsMessageCount] = useState(0)
const [sessionUpdateCount, setSessionUpdateCount] = useState(0)

const {
connected: wsConnected,
subscribeWithRetry,
sendInput,
} = useWebSocket({
const { connected, sendInput } = useWebSocket({
activeSession,
onSnapshot: useCallback((snapshot: string) => {
terminalRef.current?.reset(snapshot)
}, []),
onRawData: useCallback((rawData: string) => {
setRawOutput((prev) => {
const newOutput = prev + rawData
return newOutput
})
terminalRef.current?.write(rawData)
setWsMessageCount((prev) => prev + 1)
}, []),
onSessionList: useCallback(
(newSessions: PTYSessionInfo[], autoSelected: PTYSessionInfo | null) => {
setSessions(newSessions)
if (!autoSelected) {
return
}
setActiveSession(autoSelected)
api.session.buffer
.raw({ id: autoSelected.id })
.then((data) => {
setRawOutput(data.raw)
})
.catch((error) => {
console.error('Failed to fetch initial raw buffer for auto-selected session', error)
})
if (autoSelected) setActiveSession(autoSelected)
},
[]
),
Expand All @@ -65,11 +49,6 @@ export function App() {
}, []),
})

// Update connected from wsConnected
useEffect(() => {
setConnected(wsConnected)
}, [wsConnected])

// Periodic session list sync every 10 seconds
useEffect(() => {
const syncInterval = setInterval(async () => {
Expand All @@ -83,23 +62,17 @@ export function App() {
return () => clearInterval(syncInterval)
}, [])

const { handleSessionClick, handleSendInput, handleKillSession } = useSessionManager({
const { handleSendInput, handleKillSession } = useSessionManager({
activeSession,
setActiveSession,
subscribeWithRetry,
sendInput,
wsConnected,
onRawOutputUpdate: useCallback((rawOutput: string) => {
setRawOutput(rawOutput)
}, []),
})

return (
<div className="container" data-active-session={activeSession?.id}>
<Sidebar
sessions={sessions}
activeSession={activeSession}
onSessionClick={handleSessionClick}
onSessionClick={setActiveSession}
connected={connected}
/>
<div className="main">
Expand All @@ -114,15 +87,15 @@ export function App() {
<div className="output-container">
<RawTerminal
key={activeSession?.id}
rawOutput={rawOutput}
ref={terminalRef}
onSendInput={handleSendInput}
onInterrupt={handleKillSession}
disabled={!activeSession || activeSession.status !== 'running'}
/>
</div>
<div className="debug-info" data-testid="debug-info">
Debug: {rawOutput.length} chars, active: {activeSession?.id || 'none'}, WS raw_data:{' '}
{wsMessageCount}, session_updates: {sessionUpdateCount}
Debug: active: {activeSession?.id || 'none'}, WS raw_data: {wsMessageCount},
session_updates: {sessionUpdateCount}
</div>
</>
) : (
Expand Down
26 changes: 6 additions & 20 deletions src/web/client/components/terminal-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ declare global {
}

interface RawTerminalProps {
rawOutput: string
onSendInput?: (data: string) => void
onInterrupt?: () => void
disabled?: boolean
Expand All @@ -27,28 +26,15 @@ export class RawTerminal extends React.Component<RawTerminalProps> {

override componentDidMount() {
this.initializeTerminal()
if (this.xtermInstance && this.props.rawOutput) {
this.xtermInstance.write(this.props.rawOutput)
}
}

override componentDidUpdate(prevProps: RawTerminalProps) {
if (!this.xtermInstance) return

const currentData = this.props.rawOutput
const prevData = prevProps.rawOutput
// In band (RIS): Terminal.reset() keeps queued writes and any half-parsed escape sequence.
reset(snapshot: string): void {
this.write(`\x1bc${snapshot}`)
}

// Optimized diff-based writing - only write new content
if (currentData.startsWith(prevData)) {
const newData = currentData.slice(prevData.length)
if (newData) {
this.xtermInstance.write(newData)
}
} else {
// Session switch/truncate/etc - clear and rewrite
this.xtermInstance.clear()
this.xtermInstance.write(currentData)
}
write(chunk: string): void {
this.xtermInstance?.write(chunk)
}

override componentWillUnmount() {
Expand Down
73 changes: 73 additions & 0 deletions src/web/client/hooks/session-socket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { RETRY_DELAY } from '../../shared/constants.ts'
import type {
PTYSessionInfo,
WSMessageServerRawData,
WSMessageServerSessionList,
WSMessageServerSessionUpdate,
WSMessageServerSubscribedSession,
} from '../../shared/types.ts'

export function connectSessionSocket(
url: string,
sessionId: string | undefined,
callbacks: {
onConnected: (connected: boolean) => void
onSnapshot?: (snapshot: string) => void
onRawData?: (rawData: string) => void
onSessionList: (sessions: PTYSessionInfo[]) => void
onSessionUpdate?: (session: PTYSessionInfo) => void
}
) {
let ws: WebSocket
let retry: ReturnType<typeof setTimeout> | undefined
let closed = false

const connect = () => {
ws = new WebSocket(url)
ws.onopen = () => {
if (closed) return
callbacks.onConnected(true)
ws.send(JSON.stringify({ type: 'session_list' }))
if (sessionId) ws.send(JSON.stringify({ type: 'subscribe', sessionId }))
}
ws.onmessage = (event) => {
if (closed) return
try {
const data = JSON.parse(event.data) as
| WSMessageServerSubscribedSession
| WSMessageServerRawData
| WSMessageServerSessionList
| WSMessageServerSessionUpdate
if (data.type === 'subscribed' && data.sessionId === sessionId) {
callbacks.onSnapshot?.(data.rawData)
} else if (data.type === 'raw_data' && data.session.id === sessionId) {
callbacks.onRawData?.(data.rawData)
} else if (data.type === 'session_list') {
callbacks.onSessionList(data.sessions || [])
} else if (data.type === 'session_update') {
callbacks.onSessionUpdate?.(data.session)
}
// Ignore malformed messages without interrupting the stream.
} catch {}
}
ws.onclose = () => {
if (closed) return
callbacks.onConnected(false)
retry = setTimeout(connect, RETRY_DELAY)
}
}
connect()

return {
sendInput(sessionId: string, data: string): boolean {
if (ws.readyState !== WebSocket.OPEN) return false
ws.send(JSON.stringify({ type: 'input', sessionId, data }))
return true
},
close() {
closed = true
clearTimeout(retry)
ws.close()
},
}
}
64 changes: 5 additions & 59 deletions src/web/client/hooks/use-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,75 +5,22 @@ import { api } from '../../shared/api-client'

interface UseSessionManagerOptions {
activeSession: PTYSessionInfo | null
setActiveSession: (session: PTYSessionInfo | null) => void
subscribeWithRetry: (sessionId: string) => void
sendInput?: (sessionId: string, data: string) => void
wsConnected?: boolean
onRawOutputUpdate?: (rawOutput: string) => void
sendInput: (sessionId: string, data: string) => boolean
}

export function useSessionManager({
activeSession,
setActiveSession,
subscribeWithRetry,
sendInput,
wsConnected,
onRawOutputUpdate,
}: UseSessionManagerOptions) {
const handleSessionClick = useCallback(
async (session: PTYSessionInfo) => {
try {
// Validate session object first
if (!session?.id) {
return
}
setActiveSession(session)
onRawOutputUpdate?.('')
// Subscribe to this session for live updates
subscribeWithRetry(session.id)

try {
// Fetch raw buffer data only (processed output endpoint removed)
const rawData = await api.session.buffer
.raw({ id: session.id })
.catch(() => ({ raw: '' }))

// Call callback with raw data
onRawOutputUpdate?.(rawData.raw || '')
} catch {
onRawOutputUpdate?.('')
}
} catch {
// Ensure UI remains stable
onRawOutputUpdate?.('')
}
},
[setActiveSession, subscribeWithRetry, onRawOutputUpdate]
)

export function useSessionManager({ activeSession, sendInput }: UseSessionManagerOptions) {
const handleSendInput = useCallback(
async (data: string) => {
if (!data || !activeSession) {
// WebSocket first; HTTP while it is not open (e.g. reconnecting).
if (!data || !activeSession || sendInput(activeSession.id, data)) {
return
}

// Try WebSocket first if connected and available
if (wsConnected && sendInput) {
try {
sendInput(activeSession.id, data)
return
} catch (error) {
console.warn('WebSocket input failed, falling back to HTTP:', error)
}
}

// HTTP fallback
try {
await api.session.input({ id: activeSession.id }, { data })
// eslint-disable-next-line no-empty
} catch {}
},
[activeSession, wsConnected, sendInput]
[activeSession, sendInput]
)

const handleKillSession = useCallback(async () => {
Expand All @@ -97,7 +44,6 @@ export function useSessionManager({
}, [activeSession])

return {
handleSessionClick,
handleSendInput,
handleKillSession,
}
Expand Down
Loading