feat(vscode-ide-companion): enhance session management with pagination support

Implement cursor-based pagination for session listing and improve session handling

- Add pagination state management in useSessionManagement hook

- Implement handleLoadMoreSessions for infinite scrolling

- Update SessionMessageHandler to support paged session requests

- Add ChatHeader component for improved UI layout

- Fix session title duplication issue

- Improve error handling in session operations
This commit is contained in:
yiliang114
2025-12-06 21:45:36 +08:00
parent e538a3d1bf
commit ad301963a6
5 changed files with 298 additions and 71 deletions

View File

@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
interface ChatHeaderProps {
currentSessionTitle: string;
onLoadSessions: () => void;
onSaveSession: () => void;
onNewSession: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
currentSessionTitle,
onLoadSessions,
onSaveSession: _onSaveSession,
onNewSession,
}) => (
<div
className="flex gap-1 select-none py-1.5 px-2.5"
style={{
borderBottom: '1px solid var(--app-primary-border-color)',
backgroundColor: 'var(--app-header-background)',
}}
>
{/* Past Conversations Button */}
<button
className="flex-none py-1 px-2 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none font-medium transition-colors duration-200 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
style={{
borderRadius: 'var(--corner-radius-small)',
color: 'var(--app-primary-foreground)',
fontSize: 'var(--vscode-chat-font-size, 13px)',
}}
onClick={onLoadSessions}
title="Past conversations"
>
<span className="flex items-center gap-1">
<span style={{ fontSize: 'var(--vscode-chat-font-size, 13px)' }}>
{currentSessionTitle}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="w-3.5 h-3.5"
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
></path>
</svg>
</span>
</button>
{/* Spacer */}
<div className="flex-1"></div>
{/* Save Session Button */}
{/* <button
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
style={{
color: 'var(--app-primary-foreground)',
}}
onClick={onSaveSession}
title="Save Conversation"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
className="w-4 h-4"
>
<path
fillRule="evenodd"
d="M4.25 2A2.25 2.25 0 0 0 2 4.25v11.5A2.25 2.25 0 0 0 4.25 18h11.5A2.25 2.25 0 0 0 18 15.75V8.25a.75.75 0 0 1 .217-.517l.083-.083a.75.75 0 0 1 1.061 0l2.239 2.239A.75.75 0 0 1 22 10.5v5.25a4.75 4.75 0 0 1-4.75 4.75H4.75A4.75 4.75 0 0 1 0 15.75V4.25A4.75 4.75 0 0 1 4.75 0h5a.75.75 0 0 1 0 1.5h-5ZM9.017 6.5a1.5 1.5 0 0 1 2.072.58l.43.862a1 1 0 0 0 .895.558h3.272a1.5 1.5 0 0 1 1.5 1.5v6.75a1.5 1.5 0 0 1-1.5 1.5h-7.5a1.5 1.5 0 0 1-1.5-1.5v-6.75a1.5 1.5 0 0 1 1.5-1.5h1.25a1 1 0 0 0 .895-.558l.43-.862a1.5 1.5 0 0 1 .511-.732ZM11.78 8.47a.75.75 0 0 0-1.06-1.06L8.75 9.379 7.78 8.41a.75.75 0 0 0-1.06 1.06l1.5 1.5a.75.75 0 0 0 1.06 0l2.5-2.5Z"
clipRule="evenodd"
></path>
</svg>
</button> */}
{/* New Session Button */}
<button
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
style={{
color: 'var(--app-primary-foreground)',
}}
onClick={onNewSession}
title="New Session"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
className="w-4 h-4"
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"></path>
</svg>
</button>
</div>
);

View File

@@ -17,6 +17,9 @@ interface SessionSelectorProps {
onSearchChange: (query: string) => void;
onSelectSession: (sessionId: string) => void;
onClose: () => void;
hasMore?: boolean;
isLoading?: boolean;
onLoadMore?: () => void;
}
/**
@@ -31,6 +34,9 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
onSearchChange,
onSelectSession,
onClose,
hasMore = false,
isLoading = false,
onLoadMore,
}) => {
if (!visible) {
return null;
@@ -66,7 +72,17 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
</div>
{/* Session List with Grouping */}
<div className="session-list-content overflow-y-auto flex-1 select-none p-2">
<div
className="session-list-content overflow-y-auto flex-1 select-none p-2"
onScroll={(e) => {
const el = e.currentTarget;
const distanceToBottom =
el.scrollHeight - (el.scrollTop + el.clientHeight);
if (distanceToBottom < 48 && hasMore && !isLoading) {
onLoadMore?.();
}
}}
>
{hasNoSessions ? (
<div
className="p-5 text-center text-[var(--app-secondary-foreground)]"
@@ -126,6 +142,11 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
</React.Fragment>
))
)}
{hasMore && (
<div className="p-2 text-center opacity-60 text-[0.9em]">
{isLoading ? 'Loading…' : ''}
</div>
)}
</div>
</div>
</>

View File

@@ -16,6 +16,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
private currentStreamContent = '';
private isSavingCheckpoint = false;
private loginHandler: (() => Promise<void>) | null = null;
private isTitleSet = false; // Flag to track if title has been set
canHandle(messageType: string): boolean {
return [
@@ -74,7 +75,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
break;
case 'getQwenSessions':
await this.handleGetQwenSessions();
await this.handleGetQwenSessions(
(data?.cursor as number | undefined) ?? undefined,
(data?.size as number | undefined) ?? undefined,
);
break;
case 'saveSession':
@@ -231,8 +235,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
);
}
// Generate title for first message
if (isFirstMessage) {
// Generate title for first message, but only if it hasn't been set yet
if (isFirstMessage && !this.isTitleSet) {
const title = text.substring(0, 50) + (text.length > 50 ? '...' : '');
this.sendToWebView({
type: 'sessionTitleUpdated',
@@ -241,6 +245,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
title,
},
});
this.isTitleSet = true; // Mark title as set
}
// Save user message
@@ -280,33 +285,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
vscode.window.showInformationMessage(
'Please wait while we connect to Qwen Code...',
);
await vscode.commands.executeCommand('qwenCode.login');
}
}
return;
}
// Validate current session before sending message
const isSessionValid = await this.agentManager.checkSessionValidity();
if (!isSessionValid) {
console.warn('[SessionMessageHandler] Current session is not valid');
// Show non-modal notification with Login button
const result = await vscode.window.showWarningMessage(
'Your session has expired. Please login again to continue using Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
// Use login handler directly
if (this.loginHandler) {
await this.loginHandler();
} else {
// Fallback to command
vscode.window.showInformationMessage(
'Please wait while we connect to Qwen Code...',
);
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
return;
@@ -379,7 +358,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
console.error('[SessionMessageHandler] Error sending message:', error);
const err = error as unknown as Error;
const errorMsg = String(error);
// Safely convert error to string
const errorMsg = error ? String(error) : 'Unknown error';
const lower = errorMsg.toLowerCase();
// Suppress user-cancelled/aborted errors (ESC/Stop button)
@@ -420,7 +400,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -456,7 +436,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
} else {
return;
@@ -489,13 +469,17 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'conversationCleared',
data: {},
});
// Reset title flag when creating a new session
this.isTitleSet = false;
} catch (error) {
console.error(
'[SessionMessageHandler] Failed to create new session:',
error,
);
const errorMsg = String(error);
// Safely convert error to string
const errorMsg = error ? String(error) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -514,7 +498,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -551,7 +535,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
} else if (selection === 'View Offline') {
// Show messages from local cache only
@@ -593,8 +577,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
// Get session details
let sessionDetails = null;
// Get session details (includes cwd and filePath when using ACP)
let sessionDetails: Record<string, unknown> | null = null;
try {
const allSessions = await this.agentManager.getSessionList();
sessionDetails = allSessions.find(
@@ -613,8 +597,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Try to load session via ACP (now we should be connected)
try {
const loadResponse =
await this.agentManager.loadSessionViaAcp(sessionId);
const loadResponse = await this.agentManager.loadSessionViaAcp(
sessionId,
(sessionDetails?.cwd as string | undefined) || undefined,
);
console.log(
'[SessionMessageHandler] session/load succeeded:',
loadResponse,
@@ -628,13 +614,21 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'qwenSessionSwitched',
data: { sessionId, messages, session: sessionDetails },
});
// Reset title flag when switching sessions
this.isTitleSet = false;
// Successfully loaded session, return early to avoid fallback logic
return;
} catch (loadError) {
console.warn(
'[SessionMessageHandler] session/load failed, using fallback:',
loadError,
);
const errorMsg = String(loadError);
// Safely convert error to string
const errorMsg = loadError ? String(loadError) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -653,7 +647,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -681,16 +675,29 @@ export class SessionMessageHandler extends BaseMessageHandler {
data: { sessionId, messages, session: sessionDetails },
});
vscode.window.showWarningMessage(
'Session restored from local cache. Some context may be incomplete.',
);
// Only show the cache warning if we actually fell back to local cache
// and didn't successfully load via ACP
// Check if we truly fell back by checking if loadError is not null/undefined
// and if it's not a successful response that looks like an error
if (
loadError &&
typeof loadError === 'object' &&
!('result' in loadError)
) {
vscode.window.showWarningMessage(
'Session restored from local cache. Some context may be incomplete.',
);
}
} catch (createError) {
console.error(
'[SessionMessageHandler] Failed to create session:',
createError,
);
const createErrorMsg = String(createError);
// Safely convert error to string
const createErrorMsg = createError
? String(createError)
: 'Unknown error';
// Check for authentication/session expiration errors in session creation
if (
createErrorMsg.includes('Authentication required') ||
@@ -709,7 +716,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -738,7 +745,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
} catch (error) {
console.error('[SessionMessageHandler] Failed to switch session:', error);
const errorMsg = String(error);
// Safely convert error to string
const errorMsg = error ? String(error) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -757,7 +765,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -778,17 +786,31 @@ export class SessionMessageHandler extends BaseMessageHandler {
/**
* Handle get Qwen sessions request
*/
private async handleGetQwenSessions(): Promise<void> {
private async handleGetQwenSessions(
cursor?: number,
size?: number,
): Promise<void> {
try {
const sessions = await this.agentManager.getSessionList();
// Paged when possible; falls back to full list if ACP not supported
const page = await this.agentManager.getSessionListPaged({
cursor,
size,
});
const append = typeof cursor === 'number';
this.sendToWebView({
type: 'qwenSessionList',
data: { sessions },
data: {
sessions: page.sessions,
nextCursor: page.nextCursor,
hasMore: page.hasMore,
append,
},
});
} catch (error) {
console.error('[SessionMessageHandler] Failed to get sessions:', error);
const errorMsg = String(error);
// Safely convert error to string
const errorMsg = error ? String(error) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -807,7 +829,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -851,7 +873,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
data: response,
});
} catch (acpError) {
const errorMsg = String(acpError);
// Safely convert error to string
const errorMsg = acpError ? String(acpError) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -870,7 +893,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -898,7 +921,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
} catch (error) {
console.error('[SessionMessageHandler] Failed to save session:', error);
const errorMsg = String(error);
// Safely convert error to string
const errorMsg = error ? String(error) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -917,7 +941,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -983,7 +1007,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
} else if (selection === 'View Offline') {
const messages =
@@ -1014,8 +1038,16 @@ export class SessionMessageHandler extends BaseMessageHandler {
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
// Reset title flag when resuming sessions
this.isTitleSet = false;
// Successfully loaded session, return early to avoid fallback logic
await this.handleGetQwenSessions();
return;
} catch (acpError) {
const errorMsg = String(acpError);
// Safely convert error to string
const errorMsg = acpError ? String(acpError) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -1034,7 +1066,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}
@@ -1065,7 +1097,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
} catch (error) {
console.error('[SessionMessageHandler] Failed to resume session:', error);
const errorMsg = String(error);
// Safely convert error to string
const errorMsg = error ? String(error) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
@@ -1084,7 +1117,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
}

View File

@@ -21,6 +21,11 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
const [showSessionSelector, setShowSessionSelector] = useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
const [nextCursor, setNextCursor] = useState<number | undefined>(undefined);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isLoading, setIsLoading] = useState<boolean>(false);
const PAGE_SIZE = 20;
/**
* Filter session list
@@ -44,10 +49,24 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
* Load session list
*/
const handleLoadQwenSessions = useCallback(() => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
// Reset pagination state and load first page
setQwenSessions([]);
setNextCursor(undefined);
setHasMore(true);
setIsLoading(true);
vscode.postMessage({ type: 'getQwenSessions', data: { size: PAGE_SIZE } });
setShowSessionSelector(true);
}, [vscode]);
const handleLoadMoreSessions = useCallback(() => {
if (!hasMore || isLoading || nextCursor === undefined) return;
setIsLoading(true);
vscode.postMessage({
type: 'getQwenSessions',
data: { cursor: nextCursor, size: PAGE_SIZE },
});
}, [hasMore, isLoading, nextCursor, vscode]);
/**
* Create new session
*/
@@ -117,6 +136,9 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
sessionSearchQuery,
filteredSessions,
savedSessionTags,
nextCursor,
hasMore,
isLoading,
// State setters
setQwenSessions,
@@ -125,6 +147,9 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
setShowSessionSelector,
setSessionSearchQuery,
setSavedSessionTags,
setNextCursor,
setHasMore,
setIsLoading,
// Operations
handleLoadQwenSessions,
@@ -132,5 +157,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
handleSwitchSession,
handleSaveSession,
handleSaveSessionResponse,
handleLoadMoreSessions,
};
};

View File

@@ -10,18 +10,27 @@ import type { Conversation } from '../../storage/conversationStore.js';
import type {
PermissionOption,
ToolCall as PermissionToolCall,
} from '../components/PermissionRequest.js';
import type { PlanEntry } from '../components/PlanDisplay.js';
} from '../components/PermissionDrawer/PermissionRequest.js';
import type { ToolCallUpdate } from '../types/toolCall.js';
import type { PlanEntry } from '../../agents/qwenTypes.js';
interface UseWebViewMessagesProps {
// Session management
sessionManagement: {
currentSessionId: string | null;
setQwenSessions: (sessions: Array<Record<string, unknown>>) => void;
setQwenSessions: (
sessions:
| Array<Record<string, unknown>>
| ((
prev: Array<Record<string, unknown>>,
) => Array<Record<string, unknown>>),
) => void;
setCurrentSessionId: (id: string | null) => void;
setCurrentSessionTitle: (title: string) => void;
setShowSessionSelector: (show: boolean) => void;
setNextCursor: (cursor: number | undefined) => void;
setHasMore: (hasMore: boolean) => void;
setIsLoading: (loading: boolean) => void;
handleSaveSessionResponse: (response: {
success: boolean;
message?: string;
@@ -487,8 +496,19 @@ export const useWebViewMessages = ({
}
case 'qwenSessionList': {
const sessions = message.data.sessions || [];
handlers.sessionManagement.setQwenSessions(sessions);
const sessions =
(message.data.sessions as Array<Record<string, unknown>>) || [];
const append = Boolean(message.data.append);
const nextCursor = message.data.nextCursor as number | undefined;
const hasMore = Boolean(message.data.hasMore);
handlers.sessionManagement.setQwenSessions(
(prev: Array<Record<string, unknown>>) =>
append ? [...prev, ...sessions] : sessions,
);
handlers.sessionManagement.setNextCursor(nextCursor);
handlers.sessionManagement.setHasMore(hasMore);
handlers.sessionManagement.setIsLoading(false);
if (
handlers.sessionManagement.currentSessionId &&
sessions.length > 0
@@ -533,8 +553,26 @@ export const useWebViewMessages = ({
} else {
handlers.messageHandling.clearMessages();
}
// Clear and restore tool calls if provided in session data
handlers.clearToolCalls();
handlers.setPlanEntries([]);
if (message.data.toolCalls && Array.isArray(message.data.toolCalls)) {
message.data.toolCalls.forEach((toolCall: unknown) => {
if (toolCall && typeof toolCall === 'object') {
handlers.handleToolCallUpdate(toolCall as ToolCallUpdate);
}
});
}
// Restore plan entries if provided
if (
message.data.planEntries &&
Array.isArray(message.data.planEntries)
) {
handlers.setPlanEntries(message.data.planEntries);
} else {
handlers.setPlanEntries([]);
}
lastPlanSnapshotRef.current = null;
break;