Merge remote-tracking branch 'gitea/master' into sidecar-app-store
This commit is contained in:
@@ -15,14 +15,39 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
||||
const retryRef = useRef(0);
|
||||
const retryTimeoutRef = useRef<number | null>(null);
|
||||
const isCleaningUpRef = useRef(false);
|
||||
/** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
onMessageRef.current = onMessage;
|
||||
const onOpenRef = useRef(onOpen);
|
||||
onOpenRef.current = onOpen;
|
||||
|
||||
/**
|
||||
* Messages typed before the socket was ready.
|
||||
*
|
||||
* `send` used to drop them: `readyState !== OPEN` returned, silently, with no error and no retry — so
|
||||
* pressing enter did nothing and the turn never happened. That window is not rare. React's dev
|
||||
* StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a
|
||||
* reconnect after a drop reopens it again; with several chat panes on screen there are several sockets
|
||||
* doing this at once. One of them is always briefly not OPEN.
|
||||
*
|
||||
* Queued and flushed on open, in order. The mobile chat app does exactly this and for exactly this
|
||||
* reason — the composer is allowed to fire before the transport is ready.
|
||||
*/
|
||||
const pendingRef = useRef<string[]>([]);
|
||||
|
||||
const connect = () => {
|
||||
if (isCleaningUpRef.current) return;
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
|
||||
// CONNECTING counts as ours, not just OPEN. The deferred teardown below keeps a remount's socket
|
||||
// alive mid-handshake, and this is what reclaims it: checking only OPEN meant an effect re-run
|
||||
// built a SECOND socket and orphaned the first, which then stayed open forever with its own `open`
|
||||
// handler bailing on the socketRef mismatch.
|
||||
//
|
||||
// This is per-instance and does NOT explain the two sockets a /chat/new load opens — measured with
|
||||
// a WebSocket-constructor counter, those come from two separate `useChat` instances mounting, each
|
||||
// with its own refs. Unresolved, and tracked separately; both connect, so it reads as healthy.
|
||||
const existing = socketRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return;
|
||||
|
||||
const socket = new WebSocket(url);
|
||||
socketRef.current = socket;
|
||||
@@ -31,7 +56,12 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
||||
if (socketRef.current !== socket) return;
|
||||
setIsConnected(true);
|
||||
retryRef.current = 0;
|
||||
// BEFORE onOpen, deliberately: onOpen sends the resume/attach handshake, and anything the user
|
||||
// typed while connecting belongs after that, not in front of it.
|
||||
const queued = pendingRef.current;
|
||||
pendingRef.current = [];
|
||||
onOpenRef.current?.();
|
||||
for (const message of queued) socket.send(message);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (ev) => {
|
||||
@@ -59,25 +89,56 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// A pending teardown from a remount that is about to be undone — see below.
|
||||
if (closeTimerRef.current !== null) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
isCleaningUpRef.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
/**
|
||||
* Close LATER, not now.
|
||||
*
|
||||
* Closing here directly is correct for a real unmount and disastrous for a remount, and this hook
|
||||
* cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect
|
||||
* (mount → unmount → mount), and a subtree that is re-created — a resolved transcript, a parent key
|
||||
* change — does the same. Each time, the socket was closed while still CONNECTING, the browser
|
||||
* logged "closed before the connection is established", and the replacement was closed in turn, so
|
||||
* the view could churn forever and never hold a connection while sitting on Disconnected.
|
||||
*
|
||||
* Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and
|
||||
* cancels this timer, so the live socket is kept and the handshake completes. A real unmount has
|
||||
* nobody to cancel it and the socket closes a frame later, which costs nothing.
|
||||
*/
|
||||
isCleaningUpRef.current = true;
|
||||
if (retryTimeoutRef.current !== null) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
}
|
||||
const socket = socketRef.current;
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null;
|
||||
if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it
|
||||
if (socket) socket.close();
|
||||
if (socketRef.current === socket) socketRef.current = null;
|
||||
}, 0);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const send = (data: Record<string, unknown>) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(JSON.stringify(data));
|
||||
const message = JSON.stringify(data);
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(message);
|
||||
return;
|
||||
}
|
||||
// Not open yet, or reconnecting. Hold it rather than dropping it — see `pendingRef`. Bounded so a
|
||||
// socket that never comes back cannot grow this without limit; the oldest go first, because the
|
||||
// newest message is the one the user is still waiting on.
|
||||
pendingRef.current.push(message);
|
||||
if (pendingRef.current.length > 50) pendingRef.current.shift();
|
||||
};
|
||||
|
||||
return { isConnected, send };
|
||||
|
||||
@@ -10,6 +10,24 @@ import type { SelectedSession } from './ChatDetailPanel';
|
||||
import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
|
||||
import { PwdSelector } from './PwdSelector';
|
||||
|
||||
/**
|
||||
* Which agent owns a conversation, said on EVERY row.
|
||||
*
|
||||
* Only OpenCode rows carried a badge before, which marks one harness and leaves the other inferred from
|
||||
* its absence — fine when you already know the list mixes two, useless when you don't. The list is
|
||||
* merged from two stores (Claude transcripts, OpenCode's session db) and the harness decides where a
|
||||
* reply is dispatched, so it is a property of the row, not a footnote on the odd one out.
|
||||
*
|
||||
* `harness` is absent on older Claude rows, so anything that is not OpenCode reads as Claude — the same
|
||||
* default the server applies.
|
||||
*/
|
||||
const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) =>
|
||||
harness === 'opencode' ? (
|
||||
<span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">OpenCode</span>
|
||||
) : (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">Claude</span>
|
||||
);
|
||||
|
||||
// Reads the /chat conversation list from Claude's own transcript store (source of truth).
|
||||
// Clicking a session loads its transcript and continues the real Claude session via --resume.
|
||||
export const SessionList = () => {
|
||||
@@ -194,13 +212,14 @@ export const SessionList = () => {
|
||||
className="min-w-0 flex-1"
|
||||
meta={[
|
||||
<RelativeTime key="t" value={session.updatedAt} />,
|
||||
session.harness === 'opencode' ? (
|
||||
<span key="h" className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">
|
||||
OpenCode
|
||||
</span>
|
||||
) : (
|
||||
`${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`
|
||||
),
|
||||
<HarnessBadge key="h" harness={session.harness} />,
|
||||
// Claude's transcripts give a real turn count. OpenCode's session list has no
|
||||
// count field, so the number on those rows is a hardcoded 0 and costs an HTTP
|
||||
// call per row to make real — the badge stands in its place rather than beside a
|
||||
// zero that means "not asked".
|
||||
session.harness === 'opencode'
|
||||
? null
|
||||
: `${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`,
|
||||
// One row, several transcripts: this conversation was `/clear`ed and carried on,
|
||||
// and the server has spliced the parts back together. Worth saying out loud —
|
||||
// the message count above covers all of them, and opening the row shows all of
|
||||
|
||||
Reference in New Issue
Block a user