mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-18 06:12:08 +08:00
173 lines
5.1 KiB
TypeScript
173 lines
5.1 KiB
TypeScript
import { useCallback, useState } from 'react';
|
|
|
|
export interface SessionActivity {
|
|
/** Provider-supplied status line; null renders the default activity label. */
|
|
statusText: string | null;
|
|
canInterrupt: boolean;
|
|
/**
|
|
* When this request was first marked as processing (client clock). Drives
|
|
* the elapsed-time display and the stale `chat_subscribed` idle-ack guard.
|
|
*/
|
|
startedAt: number;
|
|
}
|
|
|
|
export type SessionActivityMap = ReadonlyMap<string, SessionActivity>;
|
|
|
|
export type SessionActivitySnapshot = {
|
|
sessionId: string;
|
|
statusText?: string | null;
|
|
canInterrupt?: boolean;
|
|
startedAt?: number;
|
|
};
|
|
|
|
export type MarkSessionProcessing = (
|
|
sessionId?: string | null,
|
|
activity?: { statusText?: string | null; canInterrupt?: boolean },
|
|
) => void;
|
|
|
|
export type MarkSessionIdle = (
|
|
sessionId?: string | null,
|
|
opts?: { ifStartedBefore?: number },
|
|
) => void;
|
|
|
|
export type SyncProcessingSessions = (
|
|
sessions: readonly SessionActivitySnapshot[],
|
|
) => void;
|
|
|
|
const LOCAL_ACTIVITY_GRACE_MS = 10_000;
|
|
|
|
const sessionActivityMapsMatch = (
|
|
left: ReadonlyMap<string, SessionActivity>,
|
|
right: ReadonlyMap<string, SessionActivity>,
|
|
): boolean => {
|
|
if (left.size !== right.size) {
|
|
return false;
|
|
}
|
|
|
|
for (const [sessionId, leftActivity] of left) {
|
|
const rightActivity = right.get(sessionId);
|
|
if (
|
|
!rightActivity
|
|
|| leftActivity.statusText !== rightActivity.statusText
|
|
|| leftActivity.canInterrupt !== rightActivity.canInterrupt
|
|
|| leftActivity.startedAt !== rightActivity.startedAt
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Single source of truth for which sessions are actively processing a
|
|
* request. Everything the chat UI shows (activity indicator, abort
|
|
* availability, status text) is derived from this map; terminal events
|
|
* (`complete`, abort, an authoritative idle subscribe ack) delete the entry
|
|
* atomically. Session ids are always concrete (allocated before the first
|
|
* send), so entries are keyed by real session ids only.
|
|
*/
|
|
export function useSessionProtection() {
|
|
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
|
|
new Map(),
|
|
);
|
|
|
|
const markSessionProcessing = useCallback<MarkSessionProcessing>((sessionId, activity) => {
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
setProcessingSessions((prev) => {
|
|
const existing = prev.get(sessionId);
|
|
const next: SessionActivity = {
|
|
statusText:
|
|
activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null,
|
|
canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true,
|
|
startedAt: existing?.startedAt ?? Date.now(),
|
|
};
|
|
|
|
if (
|
|
existing
|
|
&& existing.statusText === next.statusText
|
|
&& existing.canInterrupt === next.canInterrupt
|
|
) {
|
|
return prev;
|
|
}
|
|
|
|
const updated = new Map(prev);
|
|
updated.set(sessionId, next);
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const markSessionIdle = useCallback<MarkSessionIdle>((sessionId, opts) => {
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
setProcessingSessions((prev) => {
|
|
const existing = prev.get(sessionId);
|
|
if (!existing) {
|
|
return prev;
|
|
}
|
|
|
|
// Guard against stale `chat_subscribed` idle acks: if a new request
|
|
// started after the subscribe was sent, the idle ack describes the
|
|
// older request and must not clear the newer one.
|
|
if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) {
|
|
return prev;
|
|
}
|
|
|
|
const updated = new Map(prev);
|
|
updated.delete(sessionId);
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const syncProcessingSessions = useCallback<SyncProcessingSessions>((sessions) => {
|
|
const now = Date.now();
|
|
|
|
setProcessingSessions((prev) => {
|
|
const incoming = new Map<string, SessionActivitySnapshot>();
|
|
for (const session of sessions) {
|
|
if (!session.sessionId) {
|
|
continue;
|
|
}
|
|
incoming.set(session.sessionId, session);
|
|
}
|
|
|
|
const updated = new Map<string, SessionActivity>();
|
|
|
|
for (const [sessionId, snapshot] of incoming) {
|
|
const existing = prev.get(sessionId);
|
|
const snapshotStartedAt =
|
|
typeof snapshot.startedAt === 'number' && Number.isFinite(snapshot.startedAt) && snapshot.startedAt > 0
|
|
? snapshot.startedAt
|
|
: undefined;
|
|
|
|
updated.set(sessionId, {
|
|
statusText:
|
|
snapshot.statusText !== undefined ? snapshot.statusText : existing?.statusText ?? null,
|
|
canInterrupt: snapshot.canInterrupt ?? existing?.canInterrupt ?? true,
|
|
startedAt: snapshotStartedAt ?? existing?.startedAt ?? now,
|
|
});
|
|
}
|
|
|
|
for (const [sessionId, activity] of prev) {
|
|
if (!incoming.has(sessionId) && now - activity.startedAt < LOCAL_ACTIVITY_GRACE_MS) {
|
|
updated.set(sessionId, activity);
|
|
}
|
|
}
|
|
|
|
return sessionActivityMapsMatch(prev, updated) ? prev : updated;
|
|
});
|
|
}, []);
|
|
|
|
return {
|
|
processingSessions,
|
|
markSessionProcessing,
|
|
markSessionIdle,
|
|
syncProcessingSessions,
|
|
};
|
|
}
|