mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): defer session creation until first prompt (#6066)
* fix(web-shell): defer session creation until first prompt * fix(web-shell): stabilize deferred session attach * docs(web-shell): document optional session change callback * test(web-shell): cover deferred session setup failures * fix(webui): preserve concurrent session load on clear * fix(web-shell): keep session prep helper in utils * fix(web-shell): harden deferred session lifecycle * fix(web-shell): guard deferred session races * fix(web-shell): simplify controlled session selection * fix(web-shell): tighten empty session edge cases * fix(web-shell): tighten deferred session lifecycle * test(webui): align session action mocks with daemon types * fix(web-shell): notify cleared session ids * fix(webui): guard session cleanup races * fix(web-shell): preserve blocked local commands --------- Co-authored-by: ytahdn <ytahdn@gmail.com> Co-authored-by: 易良 <1204183885@qq.com>
This commit is contained in:
parent
487fb45510
commit
1467ed3100
24 changed files with 2328 additions and 450 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -174,32 +174,33 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = {
|
|||
* and risking transcript wipes if reconnect later attaches a different
|
||||
* session and hits the sessionId-change `store.reset()` branch.
|
||||
*
|
||||
* 404/410 (session-not-found) normally keep the reconnect-then-recreate
|
||||
* behavior, unless the caller opts into leaving missing sessions disconnected.
|
||||
* 404/410 (session-not-found) leave the requested session disconnected instead
|
||||
* of silently creating a replacement empty session.
|
||||
*/
|
||||
const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]);
|
||||
const UNHANDLED_SESSION = Symbol('unhandled session');
|
||||
|
||||
export function DaemonSessionProvider({
|
||||
baseUrl,
|
||||
token,
|
||||
workspaceCwd,
|
||||
initialSessionId,
|
||||
clientId,
|
||||
createSessionRequest,
|
||||
maxQueued = 1024,
|
||||
maxBlocks = DEFAULT_MAX_BLOCKS,
|
||||
suppressOwnUserEcho = true,
|
||||
includeRawEvent = false,
|
||||
autoConnect = true,
|
||||
autoReconnect = true,
|
||||
missingSessionBehavior = 'create',
|
||||
reconnectDelayMs = 1_000,
|
||||
maxReconnectDelayMs = 10_000,
|
||||
heartbeatIntervalMs = 30_000,
|
||||
heartbeatFailureThreshold = 3,
|
||||
loadWarnings,
|
||||
children,
|
||||
}: DaemonSessionProviderProps) {
|
||||
export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
||||
const {
|
||||
baseUrl,
|
||||
token,
|
||||
workspaceCwd,
|
||||
sessionId,
|
||||
clientId,
|
||||
createSessionRequest,
|
||||
maxQueued = 1024,
|
||||
maxBlocks = DEFAULT_MAX_BLOCKS,
|
||||
suppressOwnUserEcho = true,
|
||||
includeRawEvent = false,
|
||||
autoConnect = true,
|
||||
autoReconnect = true,
|
||||
reconnectDelayMs = 1_000,
|
||||
maxReconnectDelayMs = 10_000,
|
||||
heartbeatIntervalMs = 30_000,
|
||||
heartbeatFailureThreshold = 3,
|
||||
loadWarnings,
|
||||
children,
|
||||
} = props;
|
||||
const workspace = useOptionalDaemonWorkspace();
|
||||
const resolvedBaseUrl = baseUrl ?? workspace?.baseUrl;
|
||||
const resolvedToken = token ?? workspace?.token;
|
||||
|
|
@ -210,8 +211,19 @@ export function DaemonSessionProvider({
|
|||
workspaceCapabilitiesRef.current = workspace?.capabilities;
|
||||
const workspaceGetCapabilitiesRef = useRef(workspace?.getCapabilities);
|
||||
workspaceGetCapabilitiesRef.current = workspace?.getCapabilities;
|
||||
const initialRestoreSessionIdRef = useRef(sessionId);
|
||||
const initialRestoreSessionId = initialRestoreSessionIdRef.current;
|
||||
// Captured once at mount: if the host did not provide an initial session,
|
||||
// keep the provider empty until the first prompt creates one. Later
|
||||
// sessionId prop changes are handled by the controlled-session effect below.
|
||||
const shouldDeferInitialSessionCreation =
|
||||
initialRestoreSessionId === undefined;
|
||||
const resolvedWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
|
||||
resolvedWorkspaceCwdRef.current = resolvedWorkspaceCwd;
|
||||
const activeWorkspaceCwdRef = useRef(resolvedWorkspaceCwd);
|
||||
if (resolvedWorkspaceCwd) {
|
||||
activeWorkspaceCwdRef.current = resolvedWorkspaceCwd;
|
||||
}
|
||||
|
||||
const store = useMemo(
|
||||
() => createDaemonTranscriptStore({ maxBlocks }),
|
||||
|
|
@ -229,6 +241,10 @@ export function DaemonSessionProvider({
|
|||
ReturnType<typeof setTimeout> | undefined
|
||||
>(undefined);
|
||||
const heartbeatSupportedRef = useRef(false);
|
||||
const manualSessionClearRef = useRef(false);
|
||||
const skipNextCleanupDetachSessionIdRef = useRef<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const settledRestoredActivePromptSessionsRef = useRef<
|
||||
WeakSet<DaemonSessionClient>
|
||||
>(new WeakSet());
|
||||
|
|
@ -248,14 +264,18 @@ export function DaemonSessionProvider({
|
|||
createSessionRequestRef.current = createSessionRequest;
|
||||
const [promptStatus, setPromptStatus] = useState<DaemonPromptStatus>('idle');
|
||||
const [restoreSessionId, setRestoreSessionId] = useState<string | undefined>(
|
||||
initialSessionId,
|
||||
initialRestoreSessionId,
|
||||
);
|
||||
const [restoreMode, setRestoreMode] = useState<'load' | 'resume'>('load');
|
||||
const [restoreSessionNonce, setRestoreSessionNonce] = useState(0);
|
||||
const [attachSessionNonce, setAttachSessionNonce] = useState(0);
|
||||
const [newSessionNonce, setNewSessionNonce] = useState(0);
|
||||
const [connection, setConnection] = useState<DaemonConnectionState>({
|
||||
status: autoConnect ? 'connecting' : 'idle',
|
||||
...(initialRestoreSessionId ? { sessionId: initialRestoreSessionId } : {}),
|
||||
});
|
||||
const connectionRef = useRef(connection);
|
||||
connectionRef.current = connection;
|
||||
const noticeIdRef = useRef(0);
|
||||
const [notices, setNotices] = useState<DaemonSessionNotice[]>([]);
|
||||
const addNotice = useCallback<AddDaemonSessionNotice>((input) => {
|
||||
|
|
@ -284,6 +304,14 @@ export function DaemonSessionProvider({
|
|||
const [workspaceEventSignals, setWorkspaceEventSignals] =
|
||||
useState<DaemonWorkspaceEventSignals>(INITIAL_WORKSPACE_EVENT_SIGNALS);
|
||||
const hasCurrentSessionActivePromptRef = useRef<() => boolean>(() => false);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect) return undefined;
|
||||
|
|
@ -345,11 +373,26 @@ export function DaemonSessionProvider({
|
|||
let isSameSessionReconnect = false;
|
||||
let shouldInjectReplaySnapshot = false;
|
||||
let needsStoreReset = false;
|
||||
let attachedExistingSession = false;
|
||||
// Only populated when this attempt (re)loads the session: a reused
|
||||
// session object carries the snapshot from its original load, whose
|
||||
// usage may be older than the in-memory count.
|
||||
let replayTokenUsage: DaemonConnectionState['tokenUsage'];
|
||||
let replayTokenCount: number | undefined;
|
||||
if (!session) {
|
||||
const existingSession = sessionRef.current;
|
||||
if (
|
||||
existingSession &&
|
||||
!restoreSessionId &&
|
||||
!reconnectSessionId &&
|
||||
!shouldCreateFreshSession
|
||||
) {
|
||||
session = existingSession;
|
||||
reconnectSessionId = existingSession.sessionId;
|
||||
lastSessionIdRef.current = existingSession.sessionId;
|
||||
attachedExistingSession = true;
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
|
|
@ -370,6 +413,41 @@ export function DaemonSessionProvider({
|
|||
caps.features.includes('client_heartbeat');
|
||||
const effectWorkspaceCwd =
|
||||
resolvedWorkspaceCwdRef.current ?? caps.workspaceCwd;
|
||||
activeWorkspaceCwdRef.current = effectWorkspaceCwd;
|
||||
if (
|
||||
(shouldDeferInitialSessionCreation ||
|
||||
manualSessionClearRef.current) &&
|
||||
!restoreSessionId &&
|
||||
!reconnectSessionId &&
|
||||
!shouldCreateFreshSession
|
||||
) {
|
||||
const providerResult = await Promise.allSettled([
|
||||
client.workspaceProviders(),
|
||||
]);
|
||||
if (providerResult[0].status === 'rejected') {
|
||||
console.warn(
|
||||
'[DaemonSessionProvider] workspaceProviders failed in deferred connect:',
|
||||
providerResult[0].reason,
|
||||
);
|
||||
}
|
||||
const providers =
|
||||
providerResult[0].status === 'fulfilled'
|
||||
? providerResult[0].value
|
||||
: undefined;
|
||||
const providerModelStatus = mapProviderStatus(providers);
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
status: 'connected',
|
||||
workspaceCwd: effectWorkspaceCwd,
|
||||
models: providerModelStatus.models,
|
||||
currentModel: providerModelStatus.currentModel,
|
||||
currentMode: providerModelStatus.currentMode,
|
||||
contextWindow: providerModelStatus.contextWindow,
|
||||
providers,
|
||||
capabilities: caps,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const restoreMethod =
|
||||
restoreSessionId && restoreMode === 'resume'
|
||||
? DaemonSessionClient.resume
|
||||
|
|
@ -510,32 +588,40 @@ export function DaemonSessionProvider({
|
|||
hasCurrentSessionActivePromptRef.current = hasSessionActivePrompt;
|
||||
setPromptStatus(hasSessionActivePrompt() ? 'streaming' : 'idle');
|
||||
|
||||
const canReuseSessionMetadata =
|
||||
attachedExistingSession &&
|
||||
connectionRef.current.commands !== undefined &&
|
||||
connectionRef.current.skills !== undefined &&
|
||||
connectionRef.current.supportedCommands !== undefined &&
|
||||
connectionRef.current.context !== undefined;
|
||||
const [providerResult, commandResult, contextResult] =
|
||||
await Promise.allSettled([
|
||||
client.workspaceProviders(),
|
||||
activeSession.supportedCommands(),
|
||||
activeSession.context(),
|
||||
]);
|
||||
canReuseSessionMetadata
|
||||
? [undefined, undefined, undefined]
|
||||
: await Promise.allSettled([
|
||||
client.workspaceProviders(),
|
||||
activeSession.supportedCommands(),
|
||||
activeSession.context(),
|
||||
]);
|
||||
const providers =
|
||||
providerResult.status === 'fulfilled'
|
||||
providerResult?.status === 'fulfilled'
|
||||
? providerResult.value
|
||||
: undefined;
|
||||
const supportedCommands =
|
||||
commandResult.status === 'fulfilled'
|
||||
commandResult?.status === 'fulfilled'
|
||||
? commandResult.value
|
||||
: undefined;
|
||||
const context =
|
||||
contextResult.status === 'fulfilled'
|
||||
contextResult?.status === 'fulfilled'
|
||||
? contextResult.value
|
||||
: undefined;
|
||||
const loadWarningTexts = [
|
||||
providerResult.status === 'rejected'
|
||||
providerResult?.status === 'rejected'
|
||||
? loadWarningsRef.current?.models
|
||||
: undefined,
|
||||
commandResult.status === 'rejected'
|
||||
commandResult?.status === 'rejected'
|
||||
? loadWarningsRef.current?.commands
|
||||
: undefined,
|
||||
contextResult.status === 'rejected'
|
||||
contextResult?.status === 'rejected'
|
||||
? loadWarningsRef.current?.context
|
||||
: undefined,
|
||||
].filter((warning): warning is string => Boolean(warning));
|
||||
|
|
@ -560,7 +646,8 @@ export function DaemonSessionProvider({
|
|||
?.contextWindow ??
|
||||
providerContextWindow;
|
||||
const { commands, skills } = mapSupportedCommands(supportedCommands);
|
||||
const currentMode = getCurrentMode(context);
|
||||
const currentMode =
|
||||
getCurrentMode(context) ?? providerModelStatus.currentMode;
|
||||
|
||||
setConnection((current) => ({
|
||||
status: 'connected',
|
||||
|
|
@ -571,11 +658,11 @@ export function DaemonSessionProvider({
|
|||
? { clientId: activeSession.clientId }
|
||||
: {}),
|
||||
workspaceCwd: activeSession.workspaceCwd,
|
||||
commands,
|
||||
skills,
|
||||
models: sessionModels,
|
||||
currentModel: sessionCurrentModel,
|
||||
currentMode,
|
||||
commands: commands.length > 0 ? commands : current.commands,
|
||||
skills: skills.length > 0 ? skills : current.skills,
|
||||
models: sessionModels.length > 0 ? sessionModels : current.models,
|
||||
currentModel: sessionCurrentModel ?? current.currentModel,
|
||||
currentMode: currentMode ?? current.currentMode,
|
||||
displayName:
|
||||
getSessionDisplayName(activeSession.state) ??
|
||||
(current.sessionId === activeSession.sessionId
|
||||
|
|
@ -600,11 +687,11 @@ export function DaemonSessionProvider({
|
|||
: current.sessionId === activeSession.sessionId
|
||||
? (current.tokenCount ?? 0)
|
||||
: 0,
|
||||
contextWindow: sessionContextWindow,
|
||||
providers,
|
||||
supportedCommands,
|
||||
context,
|
||||
capabilities,
|
||||
contextWindow: sessionContextWindow ?? current.contextWindow,
|
||||
providers: providers ?? current.providers,
|
||||
supportedCommands: supportedCommands ?? current.supportedCommands,
|
||||
context: context ?? current.context,
|
||||
capabilities: capabilities ?? current.capabilities,
|
||||
catchingUp:
|
||||
isSameSessionReconnect ||
|
||||
activeSession.lastEventId != null ||
|
||||
|
|
@ -718,9 +805,14 @@ export function DaemonSessionProvider({
|
|||
if (pendingLoadToResolve) {
|
||||
pendingSessionLoadRef.current = undefined;
|
||||
clearTimeout(pendingLoadToResolve.timeout);
|
||||
if (
|
||||
skipNextCleanupDetachSessionIdRef.current ===
|
||||
activeSession.sessionId
|
||||
) {
|
||||
skipNextCleanupDetachSessionIdRef.current = undefined;
|
||||
}
|
||||
pendingLoadToResolve.resolve();
|
||||
}
|
||||
|
||||
let sawEvent = false;
|
||||
let resyncRequested = false;
|
||||
const requestEpochResetReload = () => {
|
||||
|
|
@ -755,6 +847,9 @@ export function DaemonSessionProvider({
|
|||
signal: abort.signal,
|
||||
maxQueued,
|
||||
})) {
|
||||
if (sessionRef.current?.sessionId !== activeSession.sessionId) {
|
||||
break;
|
||||
}
|
||||
if (!sawEvent) {
|
||||
sawEvent = true;
|
||||
reconnectAttempt = 0;
|
||||
|
|
@ -1023,6 +1118,12 @@ export function DaemonSessionProvider({
|
|||
}));
|
||||
return;
|
||||
}
|
||||
if (manualSessionClearRef.current) {
|
||||
session = undefined;
|
||||
sessionRef.current = undefined;
|
||||
hasCurrentSessionActivePromptRef.current = () => false;
|
||||
return;
|
||||
}
|
||||
if (!disposed && !abort.signal.aborted && !resyncRequested) {
|
||||
// Keep the session handle after a normal SSE close so the next
|
||||
// subscription can resume from DaemonSessionClient.lastEventId.
|
||||
|
|
@ -1055,10 +1156,6 @@ export function DaemonSessionProvider({
|
|||
const failedSessionId = session?.sessionId;
|
||||
const isAuthFailure = isAuthFailureHttpError(error);
|
||||
const isTerminal = isTerminalSessionHttpError(error);
|
||||
const shouldDisconnectMissingSession =
|
||||
isTerminal &&
|
||||
!isAuthFailure &&
|
||||
missingSessionBehavior === 'disconnect';
|
||||
if (failedSessionId && (isAuthFailure || isTerminal)) {
|
||||
const active = activePromptsRef.current.get(failedSessionId);
|
||||
active?.controller.abort();
|
||||
|
|
@ -1077,6 +1174,12 @@ export function DaemonSessionProvider({
|
|||
(pendingLoad.sessionId === restoreSessionId ||
|
||||
pendingLoad.sessionId === reconnectSessionId)
|
||||
) {
|
||||
if (
|
||||
skipNextCleanupDetachSessionIdRef.current ===
|
||||
pendingLoad.sessionId
|
||||
) {
|
||||
skipNextCleanupDetachSessionIdRef.current = undefined;
|
||||
}
|
||||
pendingSessionLoadRef.current = undefined;
|
||||
clearTimeout(pendingLoad.timeout);
|
||||
pendingLoad.reject(error);
|
||||
|
|
@ -1091,20 +1194,14 @@ export function DaemonSessionProvider({
|
|||
setConnection({ status: 'error', error: message });
|
||||
return;
|
||||
}
|
||||
if (shouldDisconnectMissingSession) {
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
status: 'disconnected',
|
||||
sessionId: undefined,
|
||||
error: message,
|
||||
catchingUp: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
reconnectSessionId = undefined;
|
||||
if (restoreSessionId) {
|
||||
setRestoreSessionId(undefined);
|
||||
}
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
status: 'disconnected',
|
||||
sessionId: undefined,
|
||||
error: message,
|
||||
catchingUp: undefined,
|
||||
}));
|
||||
return;
|
||||
} else {
|
||||
// Retriable error (network failure, timeout, etc.) — preserve
|
||||
// the session so the next iteration skips the full load() and
|
||||
|
|
@ -1168,14 +1265,20 @@ export function DaemonSessionProvider({
|
|||
hasCurrentSessionActivePromptRef.current = () => false;
|
||||
setPromptStatus('idle');
|
||||
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
|
||||
if (pendingSessionLoadRef.current) {
|
||||
const keepSessionForNextEffect =
|
||||
session?.sessionId === skipNextCleanupDetachSessionIdRef.current;
|
||||
const isUnmounting = !mountedRef.current;
|
||||
if (
|
||||
pendingSessionLoadRef.current &&
|
||||
(!keepSessionForNextEffect || isUnmounting)
|
||||
) {
|
||||
clearTimeout(pendingSessionLoadRef.current.timeout);
|
||||
pendingSessionLoadRef.current.reject(
|
||||
new DOMException('Session load interrupted by cleanup', 'AbortError'),
|
||||
);
|
||||
pendingSessionLoadRef.current = undefined;
|
||||
}
|
||||
if (session?.clientId) {
|
||||
if ((!keepSessionForNextEffect || isUnmounting) && session?.clientId) {
|
||||
void detachDaemonClient({
|
||||
baseUrl: resolvedBaseUrl!,
|
||||
token: resolvedToken,
|
||||
|
|
@ -1185,12 +1288,13 @@ export function DaemonSessionProvider({
|
|||
console.warn('[DaemonSessionProvider] detach failed:', err),
|
||||
);
|
||||
}
|
||||
sessionRef.current = undefined;
|
||||
if (!keepSessionForNextEffect || isUnmounting) {
|
||||
sessionRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
autoConnect,
|
||||
autoReconnect,
|
||||
missingSessionBehavior,
|
||||
resolvedBaseUrl,
|
||||
resolvedToken,
|
||||
workspaceCwd,
|
||||
|
|
@ -1201,8 +1305,10 @@ export function DaemonSessionProvider({
|
|||
restoreSessionId,
|
||||
restoreMode,
|
||||
restoreSessionNonce,
|
||||
attachSessionNonce,
|
||||
newSessionNonce,
|
||||
clientId,
|
||||
shouldDeferInitialSessionCreation,
|
||||
clearNotices,
|
||||
addNotice,
|
||||
]);
|
||||
|
|
@ -1210,6 +1316,7 @@ export function DaemonSessionProvider({
|
|||
useEffect(() => {
|
||||
if (
|
||||
!heartbeatSupportedRef.current ||
|
||||
connection.status !== 'connected' ||
|
||||
heartbeatIntervalMs <= 0 ||
|
||||
heartbeatFailureThreshold <= 0 ||
|
||||
!connection.sessionId
|
||||
|
|
@ -1251,7 +1358,12 @@ export function DaemonSessionProvider({
|
|||
disposed = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [connection.sessionId, heartbeatFailureThreshold, heartbeatIntervalMs]);
|
||||
}, [
|
||||
connection.sessionId,
|
||||
connection.status,
|
||||
heartbeatFailureThreshold,
|
||||
heartbeatIntervalMs,
|
||||
]);
|
||||
|
||||
const actions = useMemo<DaemonSessionActions>(
|
||||
() =>
|
||||
|
|
@ -1263,6 +1375,8 @@ export function DaemonSessionProvider({
|
|||
pendingSessionLoadRef,
|
||||
pendingSessionLoadIdRef,
|
||||
heartbeatSupportedRef,
|
||||
manualSessionClearRef,
|
||||
skipNextCleanupDetachSessionIdRef,
|
||||
passiveAssistantDoneTimerRef,
|
||||
hasSessionActivePrompt: () =>
|
||||
hasCurrentSessionActivePromptRef.current(),
|
||||
|
|
@ -1273,18 +1387,69 @@ export function DaemonSessionProvider({
|
|||
...createSessionRequestRef.current,
|
||||
sessionScope: 'thread',
|
||||
workspaceCwd:
|
||||
resolvedWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
|
||||
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
|
||||
}),
|
||||
createDetachedSession: () => {
|
||||
const client =
|
||||
workspaceClientRef.current ??
|
||||
new DaemonClient({
|
||||
baseUrl: resolvedBaseUrl!,
|
||||
token: resolvedToken,
|
||||
});
|
||||
const request = {
|
||||
...createSessionRequestRef.current,
|
||||
sessionScope: 'thread' as const,
|
||||
workspaceCwd:
|
||||
activeWorkspaceCwdRef.current ?? sessionRef.current?.workspaceCwd,
|
||||
};
|
||||
const requestClientId = clientId
|
||||
? clientIdRef.current
|
||||
: getStableClientId(undefined);
|
||||
return DaemonSessionClient.createOrAttach(
|
||||
client,
|
||||
request,
|
||||
requestClientId,
|
||||
);
|
||||
},
|
||||
getConnection: () => connectionRef.current,
|
||||
addNotice,
|
||||
setConnection,
|
||||
setPromptStatus,
|
||||
setRestoreSessionId,
|
||||
setRestoreMode,
|
||||
setRestoreSessionNonce,
|
||||
setAttachSessionNonce,
|
||||
setNewSessionNonce,
|
||||
}),
|
||||
[addNotice, store],
|
||||
[addNotice, clientId, resolvedBaseUrl, resolvedToken, store],
|
||||
);
|
||||
const lastHandledSessionIdRef = useRef<
|
||||
string | undefined | typeof UNHANDLED_SESSION
|
||||
>(UNHANDLED_SESSION);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastHandledSessionIdRef.current === sessionId) return;
|
||||
lastHandledSessionIdRef.current = sessionId;
|
||||
|
||||
const currentSessionId = connectionRef.current.sessionId;
|
||||
if (sessionId === currentSessionId) return;
|
||||
|
||||
const request = sessionId
|
||||
? actions.loadSession(sessionId, { deferTranscriptReset: true })
|
||||
: currentSessionId
|
||||
? actions.clearSession()
|
||||
: undefined;
|
||||
|
||||
if (!request) return;
|
||||
|
||||
void request.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[DaemonSessionProvider] controlled session transition failed:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
}, [actions, sessionId]);
|
||||
|
||||
return (
|
||||
<DaemonStoreContext.Provider value={store}>
|
||||
<DaemonConnectionContext.Provider value={connection}>
|
||||
|
|
|
|||
380
packages/webui/src/daemon/session/actions.test.ts
Normal file
380
packages/webui/src/daemon/session/actions.test.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DaemonSessionClient } from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
createDaemonSessionActions,
|
||||
getConnectionAfterSessionClear,
|
||||
} from './actions';
|
||||
import type {
|
||||
ActivePrompt,
|
||||
DaemonConnectionState,
|
||||
PendingSessionLoad,
|
||||
SettledPrompt,
|
||||
} from './types';
|
||||
|
||||
describe('getConnectionAfterSessionClear', () => {
|
||||
it('clears session fields for the session being detached', () => {
|
||||
const next = getConnectionAfterSessionClear(
|
||||
{
|
||||
status: 'disconnected',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'session-a',
|
||||
clientId: 'client-a',
|
||||
displayName: 'Session A',
|
||||
tokenCount: 42,
|
||||
commands: [commandInfo('old-command')],
|
||||
skills: ['old-skill'],
|
||||
supportedCommands: supportedCommandsStatus('session-a'),
|
||||
context: contextStatus('session-a'),
|
||||
catchingUp: true,
|
||||
error: 'old error',
|
||||
} as DaemonConnectionState,
|
||||
'session-a',
|
||||
);
|
||||
|
||||
expect(next).toMatchObject({
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
catchingUp: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
expect(next).not.toHaveProperty('sessionId');
|
||||
expect(next).not.toHaveProperty('clientId');
|
||||
expect(next).not.toHaveProperty('displayName');
|
||||
expect(next).not.toHaveProperty('tokenCount');
|
||||
expect(next).not.toHaveProperty('commands');
|
||||
expect(next).not.toHaveProperty('skills');
|
||||
expect(next).not.toHaveProperty('supportedCommands');
|
||||
expect(next).not.toHaveProperty('context');
|
||||
});
|
||||
|
||||
it('preserves a concurrently loaded session', () => {
|
||||
const next = getConnectionAfterSessionClear(
|
||||
{
|
||||
status: 'connecting',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'session-b',
|
||||
clientId: 'client-b',
|
||||
displayName: 'Session B',
|
||||
tokenCount: 7,
|
||||
commands: [commandInfo('new-command')],
|
||||
skills: ['new-skill'],
|
||||
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
|
||||
context: contextStatus('session-b'),
|
||||
catchingUp: true,
|
||||
error: 'old error',
|
||||
} as DaemonConnectionState,
|
||||
'session-a',
|
||||
);
|
||||
|
||||
expect(next).toMatchObject({
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'session-b',
|
||||
clientId: 'client-b',
|
||||
displayName: 'Session B',
|
||||
tokenCount: 7,
|
||||
commands: [commandInfo('new-command')],
|
||||
skills: ['new-skill'],
|
||||
supportedCommands: supportedCommandsStatus('session-b', 'new-command'),
|
||||
context: contextStatus('session-b'),
|
||||
catchingUp: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDaemonSessionActions', () => {
|
||||
it('creates from the active session client when the connection matches', async () => {
|
||||
const existingSession = createMockSession('session-a');
|
||||
const nextSession = createMockSession('session-b');
|
||||
existingSession.client.createOrAttachSession.mockResolvedValue(nextSession);
|
||||
const createDetachedSession = vi.fn();
|
||||
const { actions } = createActionsHarness({
|
||||
connection: { status: 'connected', sessionId: 'session-a' },
|
||||
createDetachedSession,
|
||||
session: existingSession,
|
||||
});
|
||||
|
||||
await expect(actions.createSession()).resolves.toBe(nextSession);
|
||||
|
||||
expect(existingSession.client.createOrAttachSession).toHaveBeenCalledOnce();
|
||||
expect(createDetachedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a detached session when no active session exists', async () => {
|
||||
const nextSession = createMockSession('session-b');
|
||||
const createDetachedSession = vi.fn(async () => nextSession);
|
||||
const { actions, sessionRef, getConnection } = createActionsHarness({
|
||||
connection: { status: 'connected' },
|
||||
createDetachedSession,
|
||||
});
|
||||
|
||||
await expect(actions.createSession()).resolves.toBe(nextSession);
|
||||
|
||||
expect(createDetachedSession).toHaveBeenCalledOnce();
|
||||
expect(sessionRef.current).toBe(nextSession);
|
||||
expect(getConnection()).toMatchObject({ sessionId: 'session-b' });
|
||||
});
|
||||
|
||||
it('does not restore a detached session after the session was cleared', async () => {
|
||||
const nextSession = createMockSession('session-b');
|
||||
const deferred = createDeferred<DaemonSessionClient>();
|
||||
const manualSessionClearRef = { current: false };
|
||||
const createDetachedSession = vi.fn(() => deferred.promise);
|
||||
const { actions, sessionRef, getConnection } = createActionsHarness({
|
||||
connection: { status: 'connected' },
|
||||
createDetachedSession,
|
||||
manualSessionClearRef,
|
||||
});
|
||||
|
||||
const createPromise = actions.createSession();
|
||||
manualSessionClearRef.current = true;
|
||||
deferred.resolve(nextSession as unknown as DaemonSessionClient);
|
||||
|
||||
await expect(createPromise).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'Session creation interrupted',
|
||||
});
|
||||
expect(nextSession.detach).toHaveBeenCalledOnce();
|
||||
expect(sessionRef.current).toBeUndefined();
|
||||
expect(getConnection()).not.toHaveProperty('sessionId');
|
||||
});
|
||||
|
||||
it('creates a detached session when the ref and connection do not match', async () => {
|
||||
const existingSession = createMockSession('session-a');
|
||||
const nextSession = createMockSession('session-b');
|
||||
const createDetachedSession = vi.fn(async () => nextSession);
|
||||
const { actions } = createActionsHarness({
|
||||
connection: { status: 'connected', sessionId: 'session-other' },
|
||||
createDetachedSession,
|
||||
session: existingSession,
|
||||
});
|
||||
|
||||
await expect(actions.createSession()).resolves.toBe(nextSession);
|
||||
|
||||
expect(existingSession.client.createOrAttachSession).not.toHaveBeenCalled();
|
||||
expect(createDetachedSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('starts an attach session load and bumps the attach nonce', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const setAttachSessionNonce = vi.fn();
|
||||
const { actions, pendingSessionLoadRef } = createActionsHarness({
|
||||
session,
|
||||
setAttachSessionNonce,
|
||||
});
|
||||
|
||||
const attachPromise = actions.attachSession();
|
||||
|
||||
expect(pendingSessionLoadRef.current).toMatchObject({
|
||||
id: 1,
|
||||
sessionId: 'session-a',
|
||||
mode: 'attach',
|
||||
});
|
||||
expect(setAttachSessionNonce).toHaveBeenCalledOnce();
|
||||
const nonceUpdater = setAttachSessionNonce.mock.calls[0]?.[0];
|
||||
expect(typeof nonceUpdater).toBe('function');
|
||||
expect(nonceUpdater?.(1)).toBe(2);
|
||||
|
||||
clearTimeout(pendingSessionLoadRef.current?.timeout);
|
||||
pendingSessionLoadRef.current?.resolve();
|
||||
await expect(attachPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports attach timeouts as attach session failures', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const session = createMockSession('session-a');
|
||||
const addNotice = vi.fn((notice) => notice);
|
||||
const { actions } = createActionsHarness({
|
||||
addNotice,
|
||||
session,
|
||||
});
|
||||
|
||||
const attachPromise = actions.attachSession();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
|
||||
await expect(attachPromise).rejects.toThrow('Session attach timed out');
|
||||
expect(addNotice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'daemon.attach_session.failed',
|
||||
operation: 'attach_session',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects attachSession when no session exists', async () => {
|
||||
const { actions } = createActionsHarness();
|
||||
|
||||
await expect(actions.attachSession()).rejects.toThrow(
|
||||
'Daemon session is not connected',
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts active prompts and rejects pending session loads when clearing', async () => {
|
||||
const controller = new AbortController();
|
||||
const session = createMockSession('session-a');
|
||||
const pendingReject = vi.fn();
|
||||
const pendingSessionLoadRef = {
|
||||
current: {
|
||||
id: 1,
|
||||
sessionId: 'session-a',
|
||||
mode: 'attach' as const,
|
||||
timeout: setTimeout(() => undefined, 30_000),
|
||||
resolve: vi.fn(),
|
||||
reject: pendingReject,
|
||||
},
|
||||
};
|
||||
const { actions } = createActionsHarness({
|
||||
activePrompts: new Map([['session-a', { controller } as ActivePrompt]]),
|
||||
pendingSessionLoadRef,
|
||||
session,
|
||||
});
|
||||
|
||||
await actions.clearSession();
|
||||
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(pendingReject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'AbortError',
|
||||
message: 'Session cleared',
|
||||
}),
|
||||
);
|
||||
expect(pendingSessionLoadRef.current).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function createActionsHarness(
|
||||
opts: {
|
||||
activePrompts?: Map<string, ActivePrompt>;
|
||||
addNotice?: ReturnType<typeof vi.fn>;
|
||||
connection?: DaemonConnectionState;
|
||||
createDetachedSession?: ReturnType<typeof vi.fn>;
|
||||
manualSessionClearRef?: { current: boolean };
|
||||
pendingSessionLoadRef?: { current: PendingSessionLoad | undefined };
|
||||
session?: ReturnType<typeof createMockSession>;
|
||||
setAttachSessionNonce?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
let connection: DaemonConnectionState = opts.connection ?? {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
};
|
||||
const sessionRef = {
|
||||
current: opts.session as unknown as DaemonSessionClient | undefined,
|
||||
};
|
||||
const activePromptsRef = {
|
||||
current: opts.activePrompts ?? new Map<string, ActivePrompt>(),
|
||||
};
|
||||
const pendingSessionLoadRef =
|
||||
opts.pendingSessionLoadRef ??
|
||||
({ current: undefined } as {
|
||||
current: PendingSessionLoad | undefined;
|
||||
});
|
||||
const actions = createDaemonSessionActions({
|
||||
store: {
|
||||
reset: vi.fn(),
|
||||
appendLocalUserMessage: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
} as never,
|
||||
sessionRef,
|
||||
activePromptsRef,
|
||||
settledPromptsRef: { current: new Map<string, SettledPrompt>() },
|
||||
pendingSessionLoadRef,
|
||||
pendingSessionLoadIdRef: { current: 0 },
|
||||
heartbeatSupportedRef: { current: false },
|
||||
manualSessionClearRef: opts.manualSessionClearRef ?? { current: false },
|
||||
skipNextCleanupDetachSessionIdRef: { current: undefined },
|
||||
passiveAssistantDoneTimerRef: { current: undefined },
|
||||
getCreateSessionRequest: () => ({ workspaceCwd: '/workspace' }),
|
||||
createDetachedSession: (opts.createDetachedSession ??
|
||||
vi.fn(
|
||||
async () =>
|
||||
createMockSession(
|
||||
'detached-session',
|
||||
) as unknown as DaemonSessionClient,
|
||||
)) as () => Promise<DaemonSessionClient>,
|
||||
getConnection: () => connection,
|
||||
hasSessionActivePrompt: () => false,
|
||||
resetCurrentSessionActivePrompt: vi.fn(),
|
||||
addNotice: opts.addNotice ?? vi.fn(),
|
||||
setConnection: (update) => {
|
||||
connection = typeof update === 'function' ? update(connection) : update;
|
||||
},
|
||||
setPromptStatus: vi.fn(),
|
||||
setRestoreSessionId: vi.fn(),
|
||||
setRestoreMode: vi.fn(),
|
||||
setRestoreSessionNonce: vi.fn(),
|
||||
setAttachSessionNonce: opts.setAttachSessionNonce ?? vi.fn(),
|
||||
setNewSessionNonce: vi.fn(),
|
||||
});
|
||||
return {
|
||||
actions,
|
||||
getConnection: () => connection,
|
||||
pendingSessionLoadRef,
|
||||
sessionRef,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSession(sessionId: string) {
|
||||
return {
|
||||
sessionId,
|
||||
workspaceCwd: '/workspace',
|
||||
clientId: `client-${sessionId}`,
|
||||
client: {
|
||||
createOrAttachSession: vi.fn(),
|
||||
setSessionApprovalMode: vi.fn(),
|
||||
listWorkspaceSessions: vi.fn(),
|
||||
closeSession: vi.fn(),
|
||||
},
|
||||
detach: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function commandInfo(name: string) {
|
||||
const raw = commandRaw(name);
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
function commandRaw(name: string) {
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
input: null,
|
||||
};
|
||||
}
|
||||
|
||||
function supportedCommandsStatus(sessionId: string, ...names: string[]) {
|
||||
return {
|
||||
v: 1 as const,
|
||||
sessionId,
|
||||
availableCommands: names.map(commandRaw),
|
||||
availableSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
function contextStatus(sessionId: string) {
|
||||
return {
|
||||
v: 1 as const,
|
||||
sessionId,
|
||||
workspaceCwd: '/workspace',
|
||||
state: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -53,8 +53,12 @@ export interface CreateDaemonSessionActionsArgs {
|
|||
pendingSessionLoadRef: RefBox<PendingSessionLoad | undefined>;
|
||||
pendingSessionLoadIdRef: RefBox<number>;
|
||||
heartbeatSupportedRef: RefBox<boolean>;
|
||||
manualSessionClearRef: RefBox<boolean>;
|
||||
skipNextCleanupDetachSessionIdRef: RefBox<string | undefined>;
|
||||
passiveAssistantDoneTimerRef: TimerRef;
|
||||
getCreateSessionRequest: () => CreateSessionRequest;
|
||||
createDetachedSession: () => Promise<DaemonSessionClient>;
|
||||
getConnection: () => DaemonConnectionState;
|
||||
hasSessionActivePrompt: () => boolean;
|
||||
resetCurrentSessionActivePrompt: () => void;
|
||||
addNotice: AddDaemonSessionNotice;
|
||||
|
|
@ -63,9 +67,34 @@ export interface CreateDaemonSessionActionsArgs {
|
|||
setRestoreSessionId: Dispatch<SetStateAction<string | undefined>>;
|
||||
setRestoreMode: Dispatch<SetStateAction<'load' | 'resume'>>;
|
||||
setRestoreSessionNonce: Dispatch<SetStateAction<number>>;
|
||||
setAttachSessionNonce: Dispatch<SetStateAction<number>>;
|
||||
setNewSessionNonce: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export function getConnectionAfterSessionClear(
|
||||
current: DaemonConnectionState,
|
||||
clearedSessionId: string | undefined,
|
||||
): DaemonConnectionState {
|
||||
const next = { ...current };
|
||||
if (!clearedSessionId || current.sessionId === clearedSessionId) {
|
||||
delete next.sessionId;
|
||||
delete next.clientId;
|
||||
delete next.displayName;
|
||||
delete next.tokenUsage;
|
||||
delete next.tokenCount;
|
||||
delete next.commands;
|
||||
delete next.skills;
|
||||
delete next.supportedCommands;
|
||||
delete next.context;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
status: 'connected',
|
||||
catchingUp: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDaemonSessionActions({
|
||||
store,
|
||||
sessionRef,
|
||||
|
|
@ -74,8 +103,12 @@ export function createDaemonSessionActions({
|
|||
pendingSessionLoadRef,
|
||||
pendingSessionLoadIdRef,
|
||||
heartbeatSupportedRef,
|
||||
manualSessionClearRef,
|
||||
skipNextCleanupDetachSessionIdRef,
|
||||
passiveAssistantDoneTimerRef,
|
||||
getCreateSessionRequest,
|
||||
createDetachedSession,
|
||||
getConnection,
|
||||
hasSessionActivePrompt,
|
||||
resetCurrentSessionActivePrompt,
|
||||
addNotice,
|
||||
|
|
@ -84,12 +117,37 @@ export function createDaemonSessionActions({
|
|||
setRestoreSessionId,
|
||||
setRestoreMode,
|
||||
setRestoreSessionNonce,
|
||||
setAttachSessionNonce,
|
||||
setNewSessionNonce,
|
||||
}: CreateDaemonSessionActionsArgs): DaemonSessionActions {
|
||||
function startSessionSwitch(
|
||||
function clearActiveSessionState() {
|
||||
for (const [, active] of activePromptsRef.current) {
|
||||
active.controller.abort();
|
||||
}
|
||||
activePromptsRef.current.clear();
|
||||
settledPromptsRef.current.clear();
|
||||
setPromptStatus('idle');
|
||||
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
|
||||
if (pendingSessionLoadRef.current) {
|
||||
if (
|
||||
skipNextCleanupDetachSessionIdRef.current ===
|
||||
pendingSessionLoadRef.current.sessionId
|
||||
) {
|
||||
skipNextCleanupDetachSessionIdRef.current = undefined;
|
||||
}
|
||||
clearTimeout(pendingSessionLoadRef.current.timeout);
|
||||
pendingSessionLoadRef.current.reject(
|
||||
new DOMException('Session cleared', 'AbortError'),
|
||||
);
|
||||
pendingSessionLoadRef.current = undefined;
|
||||
}
|
||||
store.reset();
|
||||
setRestoreSessionId(undefined);
|
||||
}
|
||||
|
||||
function startPendingSessionLoad(
|
||||
sessionId: string,
|
||||
mode: 'load' | 'resume',
|
||||
opts?: SessionSwitchOptions,
|
||||
mode: PendingSessionLoad['mode'],
|
||||
): Promise<void> {
|
||||
const loadId = pendingSessionLoadIdRef.current + 1;
|
||||
pendingSessionLoadIdRef.current = loadId;
|
||||
|
|
@ -111,7 +169,7 @@ export function createDaemonSessionActions({
|
|||
addNotice,
|
||||
`${capitalize(mode)} session failed`,
|
||||
new Error(`Session ${mode} timed out`),
|
||||
mode === 'load' ? 'load_session' : 'resume_session',
|
||||
getSessionLoadNoticeOperation(mode),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -125,6 +183,16 @@ export function createDaemonSessionActions({
|
|||
reject,
|
||||
};
|
||||
});
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
function startSessionSwitch(
|
||||
sessionId: string,
|
||||
mode: 'load' | 'resume',
|
||||
opts?: SessionSwitchOptions,
|
||||
): Promise<void> {
|
||||
manualSessionClearRef.current = false;
|
||||
const loadPromise = startPendingSessionLoad(sessionId, mode);
|
||||
const currentSessionId = sessionRef.current?.sessionId;
|
||||
const activePrompt = currentSessionId
|
||||
? activePromptsRef.current.get(currentSessionId)
|
||||
|
|
@ -489,37 +557,96 @@ export function createDaemonSessionActions({
|
|||
},
|
||||
|
||||
async createSession() {
|
||||
try {
|
||||
manualSessionClearRef.current = false;
|
||||
const session = sessionRef.current;
|
||||
const activeSession =
|
||||
session && getConnection().sessionId === session.sessionId
|
||||
? session
|
||||
: undefined;
|
||||
if (activeSession) {
|
||||
const nextSession = await withActionTimeout(
|
||||
activeSession.client.createOrAttachSession(
|
||||
getCreateSessionRequest(),
|
||||
),
|
||||
'Create session timed out',
|
||||
);
|
||||
persistStableClientId(nextSession.clientId, nextSession.sessionId);
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
const nextSession = await withActionTimeout(
|
||||
createDetachedSession(),
|
||||
'Create session timed out',
|
||||
);
|
||||
if (manualSessionClearRef.current) {
|
||||
try {
|
||||
await withActionTimeout(
|
||||
nextSession.detach(),
|
||||
'Detach cleared session timed out',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[DaemonSessionActions] detach after interrupted create failed:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new DOMException('Session creation interrupted', 'AbortError');
|
||||
}
|
||||
persistStableClientId(nextSession.clientId, nextSession.sessionId);
|
||||
sessionRef.current = nextSession;
|
||||
skipNextCleanupDetachSessionIdRef.current = nextSession.sessionId;
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
status: 'connected',
|
||||
sessionId: nextSession.sessionId,
|
||||
...(nextSession.clientId ? { clientId: nextSession.clientId } : {}),
|
||||
workspaceCwd: nextSession.workspaceCwd,
|
||||
error: undefined,
|
||||
}));
|
||||
return nextSession;
|
||||
} catch (error) {
|
||||
throw dispatchActionError(
|
||||
addNotice,
|
||||
'Create session failed',
|
||||
error,
|
||||
'create_session',
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async attachSession() {
|
||||
const session = requireSessionForAction(
|
||||
addNotice,
|
||||
sessionRef.current,
|
||||
'Create session failed',
|
||||
'create_session',
|
||||
'Attach session failed',
|
||||
'attach_session',
|
||||
);
|
||||
const nextSession = await withActionTimeout(
|
||||
session.client.createOrAttachSession(getCreateSessionRequest()),
|
||||
'Create session timed out',
|
||||
const loadPromise = startPendingSessionLoad(session.sessionId, 'attach');
|
||||
setAttachSessionNonce((nonce) => nonce + 1);
|
||||
return loadPromise;
|
||||
},
|
||||
|
||||
async clearSession() {
|
||||
const session = sessionRef.current;
|
||||
manualSessionClearRef.current = true;
|
||||
clearActiveSessionState();
|
||||
sessionRef.current = undefined;
|
||||
setConnection((current) =>
|
||||
getConnectionAfterSessionClear(current, session?.sessionId),
|
||||
);
|
||||
persistStableClientId(nextSession.clientId, nextSession.sessionId);
|
||||
return nextSession;
|
||||
if (session) {
|
||||
try {
|
||||
await withActionTimeout(session.detach(), 'Clear session timed out');
|
||||
} catch (error) {
|
||||
console.warn('[DaemonSessionActions] detach on clear failed:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async newSession() {
|
||||
for (const [, active] of activePromptsRef.current) {
|
||||
active.controller.abort();
|
||||
}
|
||||
activePromptsRef.current.clear();
|
||||
settledPromptsRef.current.clear();
|
||||
setPromptStatus('idle');
|
||||
clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef);
|
||||
if (pendingSessionLoadRef.current) {
|
||||
clearTimeout(pendingSessionLoadRef.current.timeout);
|
||||
pendingSessionLoadRef.current.reject(
|
||||
new DOMException('New session requested', 'AbortError'),
|
||||
);
|
||||
pendingSessionLoadRef.current = undefined;
|
||||
}
|
||||
store.reset();
|
||||
setRestoreSessionId(undefined);
|
||||
manualSessionClearRef.current = false;
|
||||
clearActiveSessionState();
|
||||
setNewSessionNonce((nonce) => nonce + 1);
|
||||
},
|
||||
|
||||
|
|
@ -1183,3 +1310,11 @@ function markNoticeDispatched(error: Error): Error {
|
|||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function getSessionLoadNoticeOperation(
|
||||
mode: PendingSessionLoad['mode'],
|
||||
): DaemonNoticeOperation {
|
||||
if (mode === 'resume') return 'resume_session';
|
||||
if (mode === 'attach') return 'attach_session';
|
||||
return 'load_session';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,14 @@ export function mapProviderStatus(
|
|||
): {
|
||||
models: DaemonModelInfo[];
|
||||
currentModel?: string;
|
||||
currentMode?: string;
|
||||
contextWindow?: number;
|
||||
} {
|
||||
if (!status) return { models: [] };
|
||||
const seen = new Set<string>();
|
||||
const models: DaemonModelInfo[] = [];
|
||||
let currentModel = preferredCurrentModel ?? status.current?.modelId;
|
||||
const currentMode = status.approvalMode;
|
||||
let contextWindow: number | undefined;
|
||||
|
||||
for (const provider of status.providers) {
|
||||
|
|
@ -68,7 +70,7 @@ export function mapProviderStatus(
|
|||
}
|
||||
}
|
||||
|
||||
return { models, currentModel, contextWindow };
|
||||
return { models, currentModel, currentMode, contextWindow };
|
||||
}
|
||||
|
||||
export function mapSessionContextModels(
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ export interface DaemonSessionProviderProps {
|
|||
token?: string;
|
||||
/** Workspace cwd used when creating, loading, or resuming daemon sessions. */
|
||||
workspaceCwd?: string;
|
||||
/** Session id to load on mount instead of creating or attaching automatically. */
|
||||
initialSessionId?: string;
|
||||
/** Session id to load. Undefined keeps the page empty until a prompt creates one. */
|
||||
sessionId?: string;
|
||||
/** Stable client identity to reuse for session-scoped daemon requests. */
|
||||
clientId?: string;
|
||||
/** Extra create-session options, excluding workspaceCwd which is owned by the provider. */
|
||||
|
|
@ -110,8 +110,6 @@ export interface DaemonSessionProviderProps {
|
|||
autoConnect?: boolean;
|
||||
/** Reconnect automatically after recoverable daemon/session failures. */
|
||||
autoReconnect?: boolean;
|
||||
/** Behavior when the active session is missing (404/410). Defaults to create. */
|
||||
missingSessionBehavior?: 'create' | 'disconnect';
|
||||
/** Initial reconnect delay in milliseconds. */
|
||||
reconnectDelayMs?: number;
|
||||
/** Maximum reconnect delay in milliseconds after backoff. */
|
||||
|
|
@ -152,6 +150,7 @@ export type DaemonNoticeOperation =
|
|||
| 'set_approval_mode'
|
||||
| 'submit_permission'
|
||||
| 'cancel_prompt'
|
||||
| 'attach_session'
|
||||
| 'load_session'
|
||||
| 'resume_session'
|
||||
| 'create_session'
|
||||
|
|
@ -311,7 +310,13 @@ export interface DaemonSessionActions {
|
|||
}): Promise<DaemonSessionSummary[]>;
|
||||
loadSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
|
||||
resumeSession(sessionId: string, opts?: SessionSwitchOptions): Promise<void>;
|
||||
/**
|
||||
* Create a daemon session and update local session state. Callers that need
|
||||
* transcript/event streaming must follow with `attachSession()`.
|
||||
*/
|
||||
createSession(): Promise<DaemonSession>;
|
||||
attachSession(): Promise<void>;
|
||||
clearSession(): Promise<void>;
|
||||
newSession(): Promise<void>;
|
||||
releaseSession(sessionId: string): Promise<void>;
|
||||
closeSession(): Promise<void>;
|
||||
|
|
@ -413,7 +418,7 @@ export type SettledPrompt =
|
|||
export interface PendingSessionLoad {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
mode: 'load' | 'resume';
|
||||
mode: 'load' | 'resume' | 'attach';
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue