fix: restore running session streaming state
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
status,step
|
||||||
|
DONE,定位会话切换与运行中恢复逻辑
|
||||||
|
DONE,核对本次改动是否影响该链路
|
||||||
|
DONE,查看相关运行日志和当前会话状态
|
||||||
|
DONE,修复缺失 streaming 气泡的恢复逻辑
|
||||||
|
IN_PROGRESS,验证并汇总影响范围
|
||||||
|
134
public/app.js
134
public/app.js
@@ -68,8 +68,10 @@
|
|||||||
const SIDEBAR_SWIPE_TRIGGER = 72;
|
const SIDEBAR_SWIPE_TRIGGER = 72;
|
||||||
const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42;
|
const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42;
|
||||||
const OLD_SESSION_GROUP_INITIAL_VISIBLE = 3;
|
const OLD_SESSION_GROUP_INITIAL_VISIBLE = 3;
|
||||||
|
const SESSION_GROUP_COMPACT_VISIBLE_LIMIT = 8;
|
||||||
const OLD_SESSION_COLLAPSE_DAYS = 7;
|
const OLD_SESSION_COLLAPSE_DAYS = 7;
|
||||||
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
|
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
const MODEL_OPTIONS = [
|
||||||
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
||||||
@@ -176,6 +178,7 @@
|
|||||||
let codexConfigCache = null;
|
let codexConfigCache = null;
|
||||||
let loadedHistorySessionId = null;
|
let loadedHistorySessionId = null;
|
||||||
let activeSessionLoad = null;
|
let activeSessionLoad = null;
|
||||||
|
let sessionLoadOverlayTimer = null;
|
||||||
let sidebarSwipe = null;
|
let sidebarSwipe = null;
|
||||||
let activeComposerToken = null;
|
let activeComposerToken = null;
|
||||||
let composerSuggestionTimer = null;
|
let composerSuggestionTimer = null;
|
||||||
@@ -3057,34 +3060,37 @@
|
|||||||
return session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
|
return session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitCollapsedOldSessions(sessionItems, collapseKey) {
|
function splitCollapsedSessions(sessionItems, collapseKey) {
|
||||||
const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
|
const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
const shouldCompactByCount = sessionItems.length > SESSION_GROUP_COMPACT_VISIBLE_LIMIT;
|
||||||
const visibleSessions = [];
|
const visibleSessions = [];
|
||||||
const hiddenOldSessions = [];
|
const hiddenSessions = [];
|
||||||
|
|
||||||
sessionItems.forEach((session) => {
|
sessionItems.forEach((session) => {
|
||||||
const shouldHideOldSession = (
|
const isOldSession = isOlderThanOldSessionWindow(session, nowMs);
|
||||||
|
const shouldHideByAge = isOldSession && visibleSessions.length >= OLD_SESSION_GROUP_INITIAL_VISIBLE;
|
||||||
|
const shouldHideByCount = shouldCompactByCount && visibleSessions.length >= SESSION_GROUP_COMPACT_VISIBLE_LIMIT;
|
||||||
|
const shouldHideSession = (
|
||||||
!isExpanded
|
!isExpanded
|
||||||
&& !shouldAlwaysShowOldSession(session)
|
&& !shouldAlwaysShowOldSession(session)
|
||||||
&& isOlderThanOldSessionWindow(session, nowMs)
|
&& (shouldHideByAge || shouldHideByCount)
|
||||||
&& visibleSessions.length >= OLD_SESSION_GROUP_INITIAL_VISIBLE
|
|
||||||
);
|
);
|
||||||
if (shouldHideOldSession) {
|
if (shouldHideSession) {
|
||||||
hiddenOldSessions.push(session);
|
hiddenSessions.push(session);
|
||||||
} else {
|
} else {
|
||||||
visibleSessions.push(session);
|
visibleSessions.push(session);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { visibleSessions, hiddenOldSessions };
|
return { visibleSessions, hiddenSessions };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOldSessionLoadMoreButton(hiddenCount, collapseKey, projectName = '') {
|
function createOldSessionLoadMoreButton(hiddenCount, collapseKey, projectName = '') {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'session-list-load-more';
|
button.className = 'session-list-load-more';
|
||||||
button.setAttribute('aria-label', `加载更多${projectName ? ` ${projectName}` : ''} ${hiddenCount} 条 7 天前会话`);
|
button.setAttribute('aria-label', `加载更多${projectName ? ` ${projectName}` : ''}会话,还有 ${hiddenCount} 条`);
|
||||||
button.innerHTML = `
|
button.innerHTML = `
|
||||||
<span class="session-list-load-more-title">加载更多</span>
|
<span class="session-list-load-more-title">加载更多</span>
|
||||||
<span class="session-list-load-more-meta">还有 ${hiddenCount} 条</span>
|
<span class="session-list-load-more-meta">还有 ${hiddenCount} 条</span>
|
||||||
@@ -3435,15 +3441,45 @@
|
|||||||
return `正在载入 ${title} 的完整消息记录…`;
|
return `正在载入 ${title} 的完整消息记录…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSessionLoadOverlayTimer() {
|
||||||
|
if (!sessionLoadOverlayTimer) return;
|
||||||
|
clearTimeout(sessionLoadOverlayTimer);
|
||||||
|
sessionLoadOverlayTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseSessionLoadingOverlay({ keepActiveLoad = true, allowRetry = false } = {}) {
|
||||||
|
clearSessionLoadOverlayTimer();
|
||||||
|
document.body.classList.remove('session-loading-active');
|
||||||
|
sessionLoadingOverlay.hidden = true;
|
||||||
|
sessionLoadingOverlay.setAttribute('aria-hidden', 'true');
|
||||||
|
msgInput.disabled = false;
|
||||||
|
modeSelect.disabled = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
abortBtn.disabled = false;
|
||||||
|
if (keepActiveLoad && activeSessionLoad) {
|
||||||
|
if (allowRetry) activeSessionLoad.overlayReleased = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setSessionLoading(sessionId, options = {}) {
|
function setSessionLoading(sessionId, options = {}) {
|
||||||
|
clearSessionLoadOverlayTimer();
|
||||||
const loading = !!sessionId;
|
const loading = !!sessionId;
|
||||||
const blocking = options.blocking !== false;
|
const blocking = options.blocking !== false;
|
||||||
const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : '';
|
const requestId = loading ? (options.requestId || createSessionSwitchRequestId(sessionId)) : '';
|
||||||
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null, requestId } : null;
|
activeSessionLoad = loading ? { sessionId, blocking, snapshot: null, requestId, overlayReleased: false } : null;
|
||||||
const showOverlay = !!(loading && blocking);
|
const showOverlay = !!(loading && blocking);
|
||||||
document.body.classList.toggle('session-loading-active', showOverlay);
|
if (showOverlay) {
|
||||||
sessionLoadingOverlay.hidden = !showOverlay;
|
document.body.classList.add('session-loading-active');
|
||||||
sessionLoadingOverlay.setAttribute('aria-hidden', showOverlay ? 'false' : 'true');
|
sessionLoadingOverlay.hidden = false;
|
||||||
|
sessionLoadingOverlay.setAttribute('aria-hidden', 'false');
|
||||||
|
sessionLoadOverlayTimer = setTimeout(() => {
|
||||||
|
sessionLoadOverlayTimer = null;
|
||||||
|
if (!activeSessionLoad || activeSessionLoad.sessionId !== sessionId || activeSessionLoad.requestId !== requestId) return;
|
||||||
|
releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true });
|
||||||
|
}, SESSION_LOAD_OVERLAY_TIMEOUT_MS);
|
||||||
|
} else {
|
||||||
|
releaseSessionLoadingOverlay({ keepActiveLoad: loading });
|
||||||
|
}
|
||||||
sessionLoadingLabel.textContent = loading ? (options.label || getSessionLoadLabel(sessionId)) : '正在整理消息与上下文…';
|
sessionLoadingLabel.textContent = loading ? (options.label || getSessionLoadLabel(sessionId)) : '正在整理消息与上下文…';
|
||||||
msgInput.disabled = showOverlay;
|
msgInput.disabled = showOverlay;
|
||||||
modeSelect.disabled = showOverlay;
|
modeSelect.disabled = showOverlay;
|
||||||
@@ -3491,7 +3527,7 @@
|
|||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
const blocking = options.blocking !== false;
|
const blocking = options.blocking !== false;
|
||||||
const force = options.force === true;
|
const force = options.force === true;
|
||||||
if (!force && activeSessionLoad?.sessionId === sessionId) return;
|
if (!force && activeSessionLoad?.sessionId === sessionId && !activeSessionLoad.overlayReleased) return;
|
||||||
if (!force && sessionId === currentSessionId && !activeSessionLoad) return;
|
if (!force && sessionId === currentSessionId && !activeSessionLoad) return;
|
||||||
renderEpoch++;
|
renderEpoch++;
|
||||||
loadedHistorySessionId = null;
|
loadedHistorySessionId = null;
|
||||||
@@ -4012,7 +4048,14 @@
|
|||||||
if (ws !== socket) return;
|
if (ws !== socket) return;
|
||||||
let msg;
|
let msg;
|
||||||
try { msg = JSON.parse(e.data); } catch { return; }
|
try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
handleServerMessage(msg);
|
try {
|
||||||
|
handleServerMessage(msg);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[cc-web] failed to handle server message', err, msg);
|
||||||
|
if (activeSessionLoad) {
|
||||||
|
releaseSessionLoadingOverlay({ keepActiveLoad: true, allowRetry: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
@@ -4126,10 +4169,23 @@
|
|||||||
const activeLoad = activeSessionLoad;
|
const activeLoad = activeSessionLoad;
|
||||||
const pendingNewSession = pendingNewSessionRequest;
|
const pendingNewSession = pendingNewSessionRequest;
|
||||||
const messageRequestId = String(msg.requestId || '');
|
const messageRequestId = String(msg.requestId || '');
|
||||||
|
const looksLikeCreatedSession = Array.isArray(msg.messages)
|
||||||
|
&& msg.messages.length === 0
|
||||||
|
&& !msg.historyPending
|
||||||
|
&& !msg.isRunning;
|
||||||
|
const matchesPendingNewSessionFallback = !!(pendingNewSession
|
||||||
|
&& !messageRequestId
|
||||||
|
&& looksLikeCreatedSession
|
||||||
|
&& snapshot.sessionId
|
||||||
|
&& snapshot.sessionId !== currentSessionId
|
||||||
|
&& snapshot.agent === pendingNewSession.agent
|
||||||
|
&& (!pendingNewSession.cwd || snapshot.cwd === pendingNewSession.cwd)
|
||||||
|
&& (!pendingNewSession.mode || snapshot.mode === pendingNewSession.mode));
|
||||||
const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId
|
const matchesActiveLoad = !!(activeLoad?.sessionId === msg.sessionId
|
||||||
&& (!activeLoad.requestId || activeLoad.requestId === messageRequestId));
|
&& (!activeLoad.requestId || activeLoad.requestId === messageRequestId));
|
||||||
const matchesPendingNewSession = !!(pendingNewSession
|
const matchesPendingNewSession = !!(pendingNewSession
|
||||||
&& (!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId));
|
&& ((!pendingNewSession.requestId || pendingNewSession.requestId === messageRequestId)
|
||||||
|
|| matchesPendingNewSessionFallback));
|
||||||
const canSwitchToSessionInfo = matchesActiveLoad
|
const canSwitchToSessionInfo = matchesActiveLoad
|
||||||
|| matchesPendingNewSession
|
|| matchesPendingNewSession
|
||||||
|| msg.sessionId === currentSessionId
|
|| msg.sessionId === currentSessionId
|
||||||
@@ -4144,7 +4200,10 @@
|
|||||||
renderSessionList();
|
renderSessionList();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (matchesPendingNewSession) pendingNewSessionRequest = null;
|
if (matchesPendingNewSession) {
|
||||||
|
pendingNewSessionRequest = null;
|
||||||
|
if (!matchesActiveLoad) clearSessionLoading();
|
||||||
|
}
|
||||||
applySessionSnapshot(snapshot, {
|
applySessionSnapshot(snapshot, {
|
||||||
immediate: isBlockingSessionLoad(msg.sessionId),
|
immediate: isBlockingSessionLoad(msg.sessionId),
|
||||||
suppressUnreadToast: false,
|
suppressUnreadToast: false,
|
||||||
@@ -4210,8 +4269,9 @@
|
|||||||
collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id));
|
collectClosedCollabAgentIds([msg.message]).forEach((id) => closedCollabAgentIds.add(id));
|
||||||
const welcome = messagesDiv.querySelector('.welcome-msg');
|
const welcome = messagesDiv.querySelector('.welcome-msg');
|
||||||
if (welcome) welcome.remove();
|
if (welcome) welcome.remove();
|
||||||
|
const shouldFollow = !(currentSessionRunning || isGenerating) || isNearBottom();
|
||||||
messagesDiv.appendChild(buildMsgElement(msg.message));
|
messagesDiv.appendChild(buildMsgElement(msg.message));
|
||||||
scrollToBottom();
|
followOutputIfNeeded(shouldFollow);
|
||||||
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
|
setCurrentSessionRunningState(!!getSessionMeta(currentSessionId)?.isRunning);
|
||||||
}
|
}
|
||||||
renderSessionList();
|
renderSessionList();
|
||||||
@@ -4644,6 +4704,7 @@
|
|||||||
if (!streamEl) return;
|
if (!streamEl) return;
|
||||||
const bubble = streamEl.querySelector('.msg-bubble');
|
const bubble = streamEl.querySelector('.msg-bubble');
|
||||||
if (!bubble) return;
|
if (!bubble) return;
|
||||||
|
const shouldFollow = isNearBottom();
|
||||||
|
|
||||||
if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) {
|
if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) {
|
||||||
bubble.innerHTML = '';
|
bubble.innerHTML = '';
|
||||||
@@ -4654,7 +4715,7 @@
|
|||||||
setRenderedMarkdown(textDiv, pendingText);
|
setRenderedMarkdown(textDiv, pendingText);
|
||||||
}
|
}
|
||||||
syncAssistantLastSectionButton(streamEl);
|
syncAssistantLastSectionButton(streamEl);
|
||||||
scrollToBottom();
|
followOutputIfNeeded(shouldFollow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
@@ -6169,6 +6230,7 @@
|
|||||||
if (!streamEl) return;
|
if (!streamEl) return;
|
||||||
const bubble = streamEl.querySelector('.msg-bubble');
|
const bubble = streamEl.querySelector('.msg-bubble');
|
||||||
if (!bubble) return;
|
if (!bubble) return;
|
||||||
|
const shouldFollow = isNearBottom();
|
||||||
let toolsDiv = bubble.querySelector('.msg-tools');
|
let toolsDiv = bubble.querySelector('.msg-tools');
|
||||||
if (!toolsDiv) { toolsDiv = bubble; }
|
if (!toolsDiv) { toolsDiv = bubble; }
|
||||||
|
|
||||||
@@ -6178,7 +6240,7 @@
|
|||||||
if (toolKind(tool) === 'collab_agent_tool_call') {
|
if (toolKind(tool) === 'collab_agent_tool_call') {
|
||||||
const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done);
|
const el = upsertCollabAgentToolCall(toolsDiv, toolUseId, tool, done);
|
||||||
if (el) rememberToolCallTarget(toolUseId, tool, el);
|
if (el) rememberToolCallTarget(toolUseId, tool, el);
|
||||||
scrollToBottom();
|
followOutputIfNeeded(shouldFollow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6189,7 +6251,7 @@
|
|||||||
const details = createToolCallElement(toolUseId, tool, done);
|
const details = createToolCallElement(toolUseId, tool, done);
|
||||||
existingTodo.replaceWith(details);
|
existingTodo.replaceWith(details);
|
||||||
rememberToolCallTarget(toolUseId, tool, details);
|
rememberToolCallTarget(toolUseId, tool, details);
|
||||||
scrollToBottom();
|
followOutputIfNeeded(shouldFollow);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6221,7 +6283,7 @@
|
|||||||
}
|
}
|
||||||
toolsDiv.appendChild(details);
|
toolsDiv.appendChild(details);
|
||||||
rememberToolCallTarget(toolUseId, tool, details);
|
rememberToolCallTarget(toolUseId, tool, details);
|
||||||
scrollToBottom();
|
followOutputIfNeeded(shouldFollow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _refreshGroupSummary(group) {
|
function _refreshGroupSummary(group) {
|
||||||
@@ -6474,6 +6536,14 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function followOutputIfNeeded(shouldFollow) {
|
||||||
|
if (shouldFollow) {
|
||||||
|
scrollToBottom();
|
||||||
|
} else {
|
||||||
|
updateScrollbar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Custom Scrollbar ---
|
// --- Custom Scrollbar ---
|
||||||
const scrollbarEl = document.getElementById('custom-scrollbar');
|
const scrollbarEl = document.getElementById('custom-scrollbar');
|
||||||
const thumbEl = document.getElementById('custom-scrollbar-thumb');
|
const thumbEl = document.getElementById('custom-scrollbar-thumb');
|
||||||
@@ -6592,9 +6662,9 @@
|
|||||||
projectGroups.forEach((group, groupIndex) => {
|
projectGroups.forEach((group, groupIndex) => {
|
||||||
const groupKey = getProjectCollapseKey(group);
|
const groupKey = getProjectCollapseKey(group);
|
||||||
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
|
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
|
||||||
const { visibleSessions: visibleGroupSessions, hiddenOldSessions: hiddenGroupOldSessions } = isSearchingSessions
|
const { visibleSessions: visibleGroupSessions, hiddenSessions: hiddenGroupSessions } = isSearchingSessions
|
||||||
? { visibleSessions: group.sessions, hiddenOldSessions: [] }
|
? { visibleSessions: group.sessions, hiddenSessions: [] }
|
||||||
: splitCollapsedOldSessions(group.sessions, oldSessionCollapseKey);
|
: splitCollapsedSessions(group.sessions, oldSessionCollapseKey);
|
||||||
const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey);
|
const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey);
|
||||||
const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId);
|
const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId);
|
||||||
const hasUnreadSession = group.sessions.some((session) => session.hasUnread);
|
const hasUnreadSession = group.sessions.some((session) => session.hasUnread);
|
||||||
@@ -6626,8 +6696,8 @@
|
|||||||
for (const s of visibleGroupSessions) {
|
for (const s of visibleGroupSessions) {
|
||||||
groupBody.appendChild(createSessionListItem(s));
|
groupBody.appendChild(createSessionListItem(s));
|
||||||
}
|
}
|
||||||
if (hiddenGroupOldSessions.length > 0) {
|
if (hiddenGroupSessions.length > 0) {
|
||||||
groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupOldSessions.length, oldSessionCollapseKey, group.name));
|
groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupSessions.length, oldSessionCollapseKey, group.name));
|
||||||
}
|
}
|
||||||
groupEl.appendChild(groupBody);
|
groupEl.appendChild(groupBody);
|
||||||
|
|
||||||
@@ -6644,16 +6714,16 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey();
|
const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey();
|
||||||
const { visibleSessions: visibleUngroupedSessions, hiddenOldSessions: hiddenUngroupedOldSessions } = isSearchingSessions
|
const { visibleSessions: visibleUngroupedSessions, hiddenSessions: hiddenUngroupedSessions } = isSearchingSessions
|
||||||
? { visibleSessions: ungroupedSessions, hiddenOldSessions: [] }
|
? { visibleSessions: ungroupedSessions, hiddenSessions: [] }
|
||||||
: splitCollapsedOldSessions(ungroupedSessions, ungroupedCollapseKey);
|
: splitCollapsedSessions(ungroupedSessions, ungroupedCollapseKey);
|
||||||
|
|
||||||
for (const s of visibleUngroupedSessions) {
|
for (const s of visibleUngroupedSessions) {
|
||||||
sessionList.appendChild(createSessionListItem(s));
|
sessionList.appendChild(createSessionListItem(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hiddenUngroupedOldSessions.length > 0) {
|
if (hiddenUngroupedSessions.length > 0) {
|
||||||
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedOldSessions.length, ungroupedCollapseKey));
|
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedSessions.length, ungroupedCollapseKey));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user