Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIConversationSelectionController.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

621 lines
21 KiB
TypeScript

import {
selectFailedNewActivationResolution,
selectEngineSession,
selectEngineSessionCanReload,
selectEngineSessionDetailHydrated,
selectEngineSessionStateHydrated,
selectLatestActivationForSession,
type AttentionReadRecord,
type AgentSessionEngine,
type PendingActivationIntentRecord
} from "@tutti-os/agent-activity-core";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { AgentGUIRuntime } from "../../../agentActivityRuntime";
import { translate } from "../../../i18n/index";
import type { AgentGUINodeData } from "../../../types";
import type { AgentGUIConversationSummary } from "../model/agentGuiConversationModel";
import type { AgentGUIConversationRailRevealReason } from "../model/agentGuiConversationRailViewState";
import {
forgetAgentGUISessionMemories,
rememberAgentGUIActiveConversation
} from "../model/agentGuiSessionNavigationMemory";
import { AGENT_SESSION_NOT_FOUND_ERROR } from "./agentGuiController.errors";
import { readAgentGUISurfaceDocumentExposure } from "./readAgentGUISurfaceDocumentExposure";
import {
reportAgentGUIAttentionReadDecision,
reportAgentGUIActiveConversationCleared,
reportAgentGUIConversationListProjectionSkipped
} from "./agentGuiController.reporting";
import {
isPendingNewConversationActivation,
isPendingNewConversationActivationForSession,
type useAgentGUIActivation
} from "./useAgentGUIActivation";
import {
resolveConversationSummaryById,
useAgentConversationSelection,
type ConversationIntent
} from "./useAgentConversationSelection";
type ActivationRecord = Pick<
PendingActivationIntentRecord,
| "agentSessionId"
| "agentTargetId"
| "errorMessage"
| "mode"
| "requestId"
| "status"
>;
interface UseAgentGUIConversationSelectionControllerInput {
activation: ReturnType<typeof useAgentGUIActivation>;
activeConversationId: string | null;
activeConversationIdRef: RefObject<string | null>;
activePendingActivation: ActivationRecord | null;
activeSessionReconcileErrorCode: string | null;
agentActivityRuntime: AgentGUIRuntime;
attentionReadRecordsBySessionId: Record<
string,
AttentionReadRecord | undefined
>;
conversationIdsRef: RefObject<Set<string>>;
conversationsRef: RefObject<AgentGUIConversationSummary[]>;
conversationListQuery: unknown | null;
currentUserId: string | null | undefined;
data: AgentGUINodeData;
dataRef: RefObject<AgentGUINodeData>;
intent: ConversationIntent;
isComposerHomeRef: RefObject<boolean>;
isMountedRef: RefObject<boolean>;
isSurfaceActive: boolean;
isSurfaceVisible: boolean;
loadDraftComposerOptions(): void;
loadSelectedConversationMessages(
agentSessionId: string,
options?: { force?: boolean }
): Promise<void>;
loadSessionState(agentSessionId: string): void;
markSelectedConversationDetailPending(agentSessionId: string): string | null;
nodeId?: string;
onDataChangeRef: RefObject<
(updater: (current: AgentGUINodeData) => AgentGUINodeData) => void
>;
sessionEngine: AgentSessionEngine;
setActiveConversationId: Dispatch<SetStateAction<string | null>>;
setDetailError: Dispatch<SetStateAction<string | null>>;
setIntent: Dispatch<SetStateAction<ConversationIntent>>;
setIsComposerHome: Dispatch<SetStateAction<boolean>>;
setIsLoadingMessages: Dispatch<SetStateAction<boolean>>;
setActiveMessageSession(agentSessionId: string | null): void;
clearRailRevealRequest(): void;
requestRailReveal(
agentSessionId: string,
reason: AgentGUIConversationRailRevealReason
): void;
transientConversation: AgentGUIConversationSummary | null;
workspaceId: string;
}
export function clearRolledBackAgentGUISelection(
current: AgentGUINodeData,
rolledBackAgentSessionId: string
): AgentGUINodeData {
const normalized = rolledBackAgentSessionId.trim();
return normalized
? forgetAgentGUISessionMemories(current, new Set([normalized]))
: current;
}
export function shouldMarkActiveConversationRead(input: {
activeConversationId: string;
isSurfaceActive: boolean;
isSurfaceDocumentExposed: boolean;
isSurfaceVisible: boolean;
previousActiveConversationId: string | null;
record: AttentionReadRecord | undefined;
}): boolean {
const {
activeConversationId,
isSurfaceActive,
isSurfaceDocumentExposed,
isSurfaceVisible,
previousActiveConversationId,
record
} = input;
if (!record?.isUnread) return false;
if (!isSurfaceActive || !isSurfaceVisible || !isSurfaceDocumentExposed) {
return false;
}
return (
!record.markedUnreadByUser ||
previousActiveConversationId !== activeConversationId
);
}
export function activeConversationReadDecisionReason(input: {
activeConversationId: string;
isSurfaceActive: boolean;
isSurfaceDocumentExposed: boolean;
isSurfaceVisible: boolean;
previousActiveConversationId: string | null;
record: AttentionReadRecord;
}):
| "active_selection"
| "manual_unread_current_selection"
| "reselected"
| "surface_hidden"
| "surface_inactive"
| "document_not_exposed" {
if (!input.isSurfaceVisible) return "surface_hidden";
if (!input.isSurfaceActive) return "surface_inactive";
if (!input.isSurfaceDocumentExposed) return "document_not_exposed";
if (
input.record.markedUnreadByUser &&
input.previousActiveConversationId === input.activeConversationId
) {
return "manual_unread_current_selection";
}
return input.previousActiveConversationId === input.activeConversationId
? "active_selection"
: "reselected";
}
export function shouldClearMissingAgentGUISelection(input: {
activeConversationId: string | null;
currentActiveConversationId: string | null;
reconcileErrorCode: string | null;
}): boolean {
const activeConversationId = input.activeConversationId?.trim() ?? "";
return (
activeConversationId !== "" &&
activeConversationId ===
(input.currentActiveConversationId?.trim() ?? "") &&
input.reconcileErrorCode?.trim() === AGENT_SESSION_NOT_FOUND_ERROR
);
}
export function useAgentGUIConversationSelectionController(
input: UseAgentGUIConversationSelectionControllerInput
) {
const {
activation,
activeConversationId,
activeConversationIdRef,
activePendingActivation,
activeSessionReconcileErrorCode,
agentActivityRuntime,
attentionReadRecordsBySessionId,
conversationIdsRef,
conversationsRef,
conversationListQuery,
currentUserId,
data,
dataRef,
intent,
isComposerHomeRef,
isMountedRef,
isSurfaceActive,
isSurfaceVisible,
loadDraftComposerOptions,
loadSelectedConversationMessages,
loadSessionState,
markSelectedConversationDetailPending,
nodeId,
onDataChangeRef,
sessionEngine,
setActiveConversationId,
setDetailError,
setIntent,
setIsComposerHome,
setIsLoadingMessages,
setActiveMessageSession,
clearRailRevealRequest,
requestRailReveal,
transientConversation,
workspaceId
} = input;
const previousAttentionActiveConversationIdRef = useRef<string | null>(null);
const attentionReadDiagnosticKeyRef = useRef<string | null>(null);
const rolledBackSelectionSessionIdRef = useRef<string | null>(null);
const persistedConfirmedActivationRequestIdRef = useRef<string | null>(null);
const attentionHydrationRef = useRef<{
engine: AgentSessionEngine;
key: string;
} | null>(null);
useEffect(() => {
const userId = currentUserId?.trim() ?? "";
const normalizedWorkspaceId = workspaceId.trim();
const attentionHydrationKey =
normalizedWorkspaceId && userId
? `${normalizedWorkspaceId}:${userId}`
: "";
if (!attentionHydrationKey) {
attentionHydrationRef.current = null;
} else if (
attentionHydrationRef.current?.engine !== sessionEngine ||
attentionHydrationRef.current.key !== attentionHydrationKey
) {
attentionHydrationRef.current = {
engine: sessionEngine,
key: attentionHydrationKey
};
sessionEngine.dispatch({
type: "attention/hydrateRequested",
commandId: `attention-hydrate:${attentionHydrationKey}`,
userId,
workspaceId: normalizedWorkspaceId
});
}
setActiveMessageSession(activeConversationId);
const rollbackSelection = (
agentSessionId: string,
errorMessage: string | null
) => {
rolledBackSelectionSessionIdRef.current = agentSessionId;
setActiveMessageSession(null);
activeConversationIdRef.current = null;
setActiveConversationId(null);
isComposerHomeRef.current = true;
setIsComposerHome(true);
setIntent({ tag: "home" });
clearRailRevealRequest();
onDataChangeRef.current((current) =>
clearRolledBackAgentGUISelection(current, agentSessionId)
);
if (errorMessage) setDetailError(errorMessage);
previousAttentionActiveConversationIdRef.current = null;
};
if (
activeConversationIdRef.current ===
activePendingActivation?.agentSessionId &&
selectFailedNewActivationResolution(
sessionEngine.getSnapshot(),
activePendingActivation.agentSessionId,
intent.tag === "active" ? { selectionSource: intent.source } : undefined
) === "rollback"
) {
rollbackSelection(
activePendingActivation.agentSessionId,
activePendingActivation.status === "failed"
? activePendingActivation.errorMessage ||
translate("agentHost.agentGui.sessionActivationFailed")
: null
);
return;
}
if (
shouldClearMissingAgentGUISelection({
activeConversationId,
currentActiveConversationId: activeConversationIdRef.current,
reconcileErrorCode: activeSessionReconcileErrorCode
})
) {
rollbackSelection(
activeConversationId!.trim(),
translate("agentHost.agentGui.sessionNoLongerAvailable")
);
setIsLoadingMessages(false);
return;
}
if (
activePendingActivation?.mode === "new" &&
activePendingActivation.status === "confirmed" &&
activeConversationIdRef.current ===
activePendingActivation.agentSessionId &&
persistedConfirmedActivationRequestIdRef.current !==
activePendingActivation.requestId
) {
persistedConfirmedActivationRequestIdRef.current =
activePendingActivation.requestId;
onDataChangeRef.current((current) =>
rememberAgentGUIActiveConversation(
current,
activePendingActivation.agentSessionId,
activePendingActivation.agentTargetId
)
);
}
if (!activeConversationId) {
previousAttentionActiveConversationIdRef.current = null;
return;
}
if (activeConversationIdRef.current !== activeConversationId) {
previousAttentionActiveConversationIdRef.current = null;
return;
}
const evaluateAttentionRead = () => {
const previousActiveConversationId =
previousAttentionActiveConversationIdRef.current;
previousAttentionActiveConversationIdRef.current = activeConversationId;
const attentionRecord =
attentionReadRecordsBySessionId[activeConversationId];
const isSurfaceDocumentExposed = readAgentGUISurfaceDocumentExposure();
const shouldMarkRead = shouldMarkActiveConversationRead({
activeConversationId,
isSurfaceActive,
isSurfaceDocumentExposed,
isSurfaceVisible,
previousActiveConversationId,
record: attentionRecord
});
if (attentionRecord?.isUnread) {
const reason = activeConversationReadDecisionReason({
activeConversationId,
isSurfaceActive,
isSurfaceDocumentExposed,
isSurfaceVisible,
previousActiveConversationId,
record: attentionRecord
});
const diagnosticKey = [
attentionRecord.completionKey,
isSurfaceActive,
isSurfaceDocumentExposed,
isSurfaceVisible,
previousActiveConversationId,
attentionRecord.markedUnreadByUser,
shouldMarkRead
].join(":");
if (attentionReadDiagnosticKeyRef.current !== diagnosticKey) {
attentionReadDiagnosticKeyRef.current = diagnosticKey;
reportAgentGUIAttentionReadDecision({
agentSessionId: activeConversationId,
completionKey: attentionRecord.completionKey,
decision: shouldMarkRead ? "read" : "preserve_unread",
isSurfaceActive,
isSurfaceDocumentExposed,
isSurfaceVisible,
markedUnreadByUser: attentionRecord.markedUnreadByUser,
nodeId,
previousActiveConversationId,
reason,
runtime: agentActivityRuntime,
workspaceId
});
}
} else {
attentionReadDiagnosticKeyRef.current = null;
}
if (shouldMarkRead) {
sessionEngine.dispatch({
type: "attention/read",
agentSessionId: activeConversationId,
userId: currentUserId?.trim() ?? ""
});
}
};
const handleExposureChange = () => evaluateAttentionRead();
document.addEventListener("visibilitychange", handleExposureChange);
window.addEventListener("blur", handleExposureChange);
window.addEventListener("focus", handleExposureChange);
evaluateAttentionRead();
return () => {
document.removeEventListener("visibilitychange", handleExposureChange);
window.removeEventListener("blur", handleExposureChange);
window.removeEventListener("focus", handleExposureChange);
};
}, [
activeConversationId,
activePendingActivation,
activeSessionReconcileErrorCode,
attentionReadRecordsBySessionId,
clearRailRevealRequest,
currentUserId,
isSurfaceActive,
isSurfaceVisible,
sessionEngine,
setActiveMessageSession,
workspaceId
]);
useEffect(() => {
const externalId = data.lastActiveAgentSessionId?.trim() ?? "";
const rolledBackSelectionSessionId =
rolledBackSelectionSessionIdRef.current;
if (rolledBackSelectionSessionId) {
if (externalId === rolledBackSelectionSessionId) {
// The Workbench state update that clears a failed or missing session
// can reach this component after the local rollback. Until that echo
// arrives, the old persisted id is not a new external selection.
return;
}
rolledBackSelectionSessionIdRef.current = null;
}
if (externalId === (activeConversationIdRef.current ?? "")) return;
if (!externalId) {
const previous = activeConversationIdRef.current;
if (!previous && isComposerHomeRef.current && intent.tag === "home") {
return;
}
reportAgentGUIActiveConversationCleared({
details: {
dataLastActiveAgentSessionId: data.lastActiveAgentSessionId ?? null,
intent: intent.tag,
isComposerHome: isComposerHomeRef.current
},
previousAgentSessionId: previous,
reason: "external_last_active_empty",
runtime: agentActivityRuntime,
workspaceId
});
if (
previous &&
!isPendingNewConversationActivationForSession(
activePendingActivation,
previous
)
) {
void activation.unactivate(previous);
}
setIntent({ tag: "home" });
isComposerHomeRef.current = true;
setIsComposerHome(true);
setActiveMessageSession(null);
activeConversationIdRef.current = null;
setActiveConversationId(null);
setIsLoadingMessages(false);
setDetailError(null);
clearRailRevealRequest();
loadDraftComposerOptions();
return;
}
setIntent((current) => {
if (
(current.tag === "active" || current.tag === "requested") &&
current.id === externalId
) {
return current;
}
if (current.tag === "requested") {
return current;
}
return { tag: "requested", id: externalId };
});
// External persisted selection is the trigger; routing dependencies stay in
// refs or stable controller callbacks to avoid replaying a local selection.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.lastActiveAgentSessionId]);
const selection = useAgentConversationSelection({
activation: {
canReload: (agentSessionId) => {
return selectEngineSessionCanReload(
sessionEngine.getSnapshot(),
agentSessionId
);
},
forget: activation.clearFailure,
isPending: (agentSessionId) =>
isPendingNewConversationActivation(
selectLatestActivationForSession(
sessionEngine.getSnapshot(),
agentSessionId
)
)
},
conversations: {
agentTargetIdFor: (agentSessionId) => {
const state = sessionEngine.getSnapshot();
const sessionAgentTargetId =
selectEngineSession(state, agentSessionId)?.agentTargetId?.trim() ??
"";
if (sessionAgentTargetId) return sessionAgentTargetId;
const activationAgentTargetId =
selectLatestActivationForSession(
state,
agentSessionId
)?.agentTargetId?.trim() ?? "";
if (activationAgentTargetId) return activationAgentTargetId;
return (
resolveConversationSummaryById(
conversationsRef.current,
agentSessionId,
transientConversation
)?.agentTargetId?.trim() || null
);
},
contains: (agentSessionId) =>
conversationIdsRef.current.has(agentSessionId)
},
detail: {
ensureHydrated: (agentSessionId) => {
void loadSelectedConversationMessages(agentSessionId);
},
ensureStateHydrated: loadSessionState,
isHydrated: (agentSessionId) =>
selectEngineSessionDetailHydrated(
sessionEngine.getSnapshot(),
agentSessionId
),
isStateHydrated: (agentSessionId) =>
selectEngineSessionStateHydrated(
sessionEngine.getSnapshot(),
agentSessionId
),
markPending: markSelectedConversationDetailPending,
setLoading: setIsLoadingMessages
},
hasConversationListQuery: () => Boolean(conversationListQuery),
isMounted: () => isMountedRef.current,
onMissingConversationListQuery: (previous) => {
const workspaceIdPresent = Boolean(workspaceId.trim());
const currentUserIdPresent = Boolean(currentUserId?.trim());
const diagnosticInput = {
currentUserIdPresent,
dataLastActiveAgentSessionId:
dataRef.current.lastActiveAgentSessionId ?? null,
isComposerHome: isComposerHomeRef.current,
provider: dataRef.current.provider,
runtime: agentActivityRuntime,
workspaceId,
workspaceIdPresent
};
reportAgentGUIConversationListProjectionSkipped({
...diagnosticInput,
activeConversationId: previous,
reason: "conversation_list_query_missing"
});
reportAgentGUIActiveConversationCleared({
details: {
currentUserIdPresent,
dataLastActiveAgentSessionId:
diagnosticInput.dataLastActiveAgentSessionId,
isComposerHome: diagnosticInput.isComposerHome,
provider: diagnosticInput.provider,
workspaceIdPresent
},
previousAgentSessionId: previous,
reason: "conversation_list_query_missing",
runtime: agentActivityRuntime,
workspaceId
});
},
persistence: { update: (updater) => onDataChangeRef.current(updater) },
rail: {
clearRevealRequest: clearRailRevealRequest,
requestReveal: requestRailReveal
},
selection: {
clearDetailError: () => setDetailError(null),
getActiveSessionId: () => activeConversationIdRef.current,
setActiveSessionId: (agentSessionId) => {
setActiveMessageSession(agentSessionId);
activeConversationIdRef.current = agentSessionId;
setActiveConversationId(agentSessionId);
},
setComposerHome: (home) => {
isComposerHomeRef.current = home;
setIsComposerHome(home);
},
setIntent
}
});
const removeConversations = useCallback(
(conversationIds: readonly string[]) => {
const removedIds = new Set(
conversationIds
.map((agentSessionId) => agentSessionId.trim())
.filter(Boolean)
);
for (const agentSessionId of conversationIds) {
sessionEngine.dispatch({
type: "session/removed",
agentSessionId
});
}
if (removedIds.size === 0) return;
onDataChangeRef.current((current) => {
const next = forgetAgentGUISessionMemories(current, removedIds);
dataRef.current = next;
return next;
});
},
[dataRef, onDataChangeRef, sessionEngine]
);
return { ...selection, removeConversations };
}