revert the chat tabs and panes work, back to one conversation
Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from "tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the per-server directory picker, the render-loop fix, pane transcript resolution, the send queue, the two socket fixes from the other session, the pane-socket notes, and my own socket-set change from tonight. He is rebuilding from here. Deliberately KEPT:dc6b623, "talk to two officers at once from one browser". That was a separate ask that predates the tabs one, and the multi-server client, the server chips and the connections store stand on their own without panes. Reverting it too is one more command if that was the intent. Collateral, worth naming:cb7ab55carried an unrelated MusicPlayerHost change alongside its socket instrumentation, so that came out with it. Reverts, not a reset — every one of these is pushed and a second session is live in this repo. Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing identically before this and unrelated to chat. What is NOT explained by this revert: the browser symptoms tonight. The server was verified good throughout — two real turns streamed back through the public URL on both models, and the full 2,281-message history came through nginx intact. Whatever the client fault is, it is still unfound, and the pre-tabs code is where it now has to be looked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ class SessionManager {
|
||||
cwd,
|
||||
model,
|
||||
piProcess: null,
|
||||
sockets: new Set(),
|
||||
ws: null,
|
||||
lastActivity: Date.now(),
|
||||
idleTimer: null,
|
||||
streamBuffer: '',
|
||||
@@ -145,7 +145,7 @@ class SessionManager {
|
||||
attachWs(sessionId: string, ws: any): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.sockets.add(ws);
|
||||
session.ws = ws;
|
||||
session.lastActivity = Date.now();
|
||||
|
||||
if (session.idleTimer) {
|
||||
@@ -155,24 +155,14 @@ class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
|
||||
* session's only socket field, so a stale client's close event silenced whichever client had attached
|
||||
* after it. A close is only the end of the conversation when nothing else is still watching.
|
||||
*/
|
||||
detachWs(sessionId: string, ws: any): void {
|
||||
detachWs(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.sockets.delete(ws);
|
||||
session.ws = null;
|
||||
session.lastActivity = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
|
||||
hasSockets(sessionId: string): boolean {
|
||||
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
@@ -299,13 +299,7 @@ export type UserSession = {
|
||||
cwd: string;
|
||||
model: string;
|
||||
piProcess: any | null;
|
||||
/**
|
||||
* Every socket watching this conversation, not the most recent one. Two panes in one window, or a
|
||||
* laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a
|
||||
* single `ws` field meant the newest attach silently stole the turn from everyone else, while any one
|
||||
* of them closing set it to null and killed delivery for the rest.
|
||||
*/
|
||||
sockets: Set<any>;
|
||||
ws: any | null;
|
||||
lastActivity: number;
|
||||
idleTimer: Timer | null;
|
||||
streamBuffer: string;
|
||||
|
||||
@@ -158,10 +158,8 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
||||
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (sessionId) {
|
||||
sessionManager.detachWs(sessionId, ws);
|
||||
// Only once nothing is watching. Another pane or another device still attached means the
|
||||
// conversation is live, and arming the idle GC here would collect it out from under them.
|
||||
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||
sessionManager.detachWs(sessionId);
|
||||
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +244,7 @@ function createMessageHandler(sessionId: string, model: string) {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (!session) return;
|
||||
foldIntoSession(session, msg, model);
|
||||
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
|
||||
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -653,7 +651,7 @@ async function handleResumeCursor(
|
||||
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
|
||||
// away" into a turn that was running perfectly well.
|
||||
if (msg.generating && decision.kind !== 'assume') {
|
||||
await endTurnIfAgentIsGone([ws], sessionId, decision.model);
|
||||
await endTurnIfAgentIsGone(ws, sessionId, decision.model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,7 +821,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
||||
* so its sessions are left alone rather than guessed at.
|
||||
*/
|
||||
async function endTurnIfAgentIsGone(
|
||||
targets: Iterable<ServerWebSocket<WSData> | null>,
|
||||
ws: ServerWebSocket<WSData> | null,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
): Promise<void> {
|
||||
@@ -836,11 +834,11 @@ async function endTurnIfAgentIsGone(
|
||||
const event: ServerMessage = { type: 'cut-off' };
|
||||
try {
|
||||
const seq = await appendChatEvent(sessionId, event);
|
||||
for (const target of targets) sendToClient(target, event, seq);
|
||||
sendToClient(ws, event, seq);
|
||||
} catch (err) {
|
||||
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
|
||||
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
|
||||
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
||||
for (const target of targets) sendToClient(target, event);
|
||||
sendToClient(ws, event);
|
||||
}
|
||||
logger.info('Ended a turn whose agent had gone', { sessionId });
|
||||
}
|
||||
@@ -852,11 +850,7 @@ async function endTurnIfAgentIsGone(
|
||||
sidecar.onClaudeSidecarStarted(() => {
|
||||
for (const session of sessionManager.getAllSessions()) {
|
||||
if (!session.isGenerating) continue;
|
||||
void endTurnIfAgentIsGone(
|
||||
session.sockets as Set<ServerWebSocket<WSData>>,
|
||||
session.sessionId,
|
||||
session.model,
|
||||
);
|
||||
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user