mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
WIP: All changes including session and toolcall improvements
This commit is contained in:
@@ -32,6 +32,9 @@ export class AcpConnection {
|
|||||||
private pendingRequests = new Map<number, PendingRequest<unknown>>();
|
private pendingRequests = new Map<number, PendingRequest<unknown>>();
|
||||||
private nextRequestId = { value: 0 };
|
private nextRequestId = { value: 0 };
|
||||||
private backend: AcpBackend | null = null;
|
private backend: AcpBackend | null = null;
|
||||||
|
// Remember the working dir provided at connect() so later ACP calls
|
||||||
|
// that require cwd (e.g. session/list) can include it.
|
||||||
|
private workingDir: string = process.cwd();
|
||||||
|
|
||||||
private messageHandler: AcpMessageHandler;
|
private messageHandler: AcpMessageHandler;
|
||||||
private sessionManager: AcpSessionManager;
|
private sessionManager: AcpSessionManager;
|
||||||
@@ -66,6 +69,7 @@ export class AcpConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.backend = backend;
|
this.backend = backend;
|
||||||
|
this.workingDir = workingDir;
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
@@ -310,12 +314,13 @@ export class AcpConnection {
|
|||||||
* @param sessionId - Session ID
|
* @param sessionId - Session ID
|
||||||
* @returns Load response
|
* @returns Load response
|
||||||
*/
|
*/
|
||||||
async loadSession(sessionId: string): Promise<AcpResponse> {
|
async loadSession(sessionId: string, cwdOverride?: string): Promise<AcpResponse> {
|
||||||
return this.sessionManager.loadSession(
|
return this.sessionManager.loadSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
this.child,
|
this.child,
|
||||||
this.pendingRequests,
|
this.pendingRequests,
|
||||||
this.nextRequestId,
|
this.nextRequestId,
|
||||||
|
cwdOverride || this.workingDir,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,11 +329,13 @@ export class AcpConnection {
|
|||||||
*
|
*
|
||||||
* @returns Session list response
|
* @returns Session list response
|
||||||
*/
|
*/
|
||||||
async listSessions(): Promise<AcpResponse> {
|
async listSessions(options?: { cursor?: number; size?: number }): Promise<AcpResponse> {
|
||||||
return this.sessionManager.listSessions(
|
return this.sessionManager.listSessions(
|
||||||
this.child,
|
this.child,
|
||||||
this.pendingRequests,
|
this.pendingRequests,
|
||||||
this.nextRequestId,
|
this.nextRequestId,
|
||||||
|
this.workingDir,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,11 +247,12 @@ export class AcpSessionManager {
|
|||||||
child: ChildProcess | null,
|
child: ChildProcess | null,
|
||||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||||
nextRequestId: { value: number },
|
nextRequestId: { value: number },
|
||||||
|
cwd: string = process.cwd(),
|
||||||
): Promise<AcpResponse> {
|
): Promise<AcpResponse> {
|
||||||
console.log('[ACP] Sending session/load request for session:', sessionId);
|
console.log('[ACP] Sending session/load request for session:', sessionId);
|
||||||
console.log('[ACP] Request parameters:', {
|
console.log('[ACP] Request parameters:', {
|
||||||
sessionId,
|
sessionId,
|
||||||
cwd: process.cwd(),
|
cwd,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,7 +261,7 @@ export class AcpSessionManager {
|
|||||||
AGENT_METHODS.session_load,
|
AGENT_METHODS.session_load,
|
||||||
{
|
{
|
||||||
sessionId,
|
sessionId,
|
||||||
cwd: process.cwd(),
|
cwd,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
},
|
},
|
||||||
child,
|
child,
|
||||||
@@ -278,6 +279,9 @@ export class AcpSessionManager {
|
|||||||
console.error('[ACP] Session load returned error:', response.error);
|
console.error('[ACP] Session load returned error:', response.error);
|
||||||
} else {
|
} else {
|
||||||
console.log('[ACP] Session load succeeded');
|
console.log('[ACP] Session load succeeded');
|
||||||
|
// session/load returns null on success per schema; update local sessionId
|
||||||
|
// so subsequent prompts use the loaded session.
|
||||||
|
this.sessionId = sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -302,12 +306,19 @@ export class AcpSessionManager {
|
|||||||
child: ChildProcess | null,
|
child: ChildProcess | null,
|
||||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||||
nextRequestId: { value: number },
|
nextRequestId: { value: number },
|
||||||
|
cwd: string = process.cwd(),
|
||||||
|
options?: { cursor?: number; size?: number },
|
||||||
): Promise<AcpResponse> {
|
): Promise<AcpResponse> {
|
||||||
console.log('[ACP] Requesting session list...');
|
console.log('[ACP] Requesting session list...');
|
||||||
try {
|
try {
|
||||||
|
// session/list requires cwd in params per ACP schema
|
||||||
|
const params: Record<string, unknown> = { cwd };
|
||||||
|
if (options?.cursor !== undefined) params.cursor = options.cursor;
|
||||||
|
if (options?.size !== undefined) params.size = options.size;
|
||||||
|
|
||||||
const response = await this.sendRequest<AcpResponse>(
|
const response = await this.sendRequest<AcpResponse>(
|
||||||
AGENT_METHODS.session_list,
|
AGENT_METHODS.session_list,
|
||||||
{},
|
params,
|
||||||
child,
|
child,
|
||||||
pendingRequests,
|
pendingRequests,
|
||||||
nextRequestId,
|
nextRequestId,
|
||||||
|
|||||||
@@ -220,16 +220,28 @@ export class QwenAgentManager {
|
|||||||
const response = await this.connection.listSessions();
|
const response = await this.connection.listSessions();
|
||||||
console.log('[QwenAgentManager] ACP session list response:', response);
|
console.log('[QwenAgentManager] ACP session list response:', response);
|
||||||
|
|
||||||
if (response.result && Array.isArray(response.result)) {
|
// sendRequest resolves with the JSON-RPC "result" directly
|
||||||
const sessions = response.result.map((session) => ({
|
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
|
||||||
id: session.sessionId || session.id,
|
// Older prototypes might return an array. Support both.
|
||||||
sessionId: session.sessionId || session.id,
|
const res: any = response as any;
|
||||||
title: session.title || session.name || 'Untitled Session',
|
const items: any[] = Array.isArray(res)
|
||||||
name: session.title || session.name || 'Untitled Session',
|
? res
|
||||||
startTime: session.startTime,
|
: Array.isArray(res?.items)
|
||||||
lastUpdated: session.lastUpdated,
|
? res.items
|
||||||
messageCount: session.messageCount || 0,
|
: [];
|
||||||
projectHash: session.projectHash,
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
const sessions = items.map((item) => ({
|
||||||
|
id: item.sessionId || item.id,
|
||||||
|
sessionId: item.sessionId || item.id,
|
||||||
|
title: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
|
name: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
|
startTime: item.startTime,
|
||||||
|
lastUpdated: item.mtime || item.lastUpdated,
|
||||||
|
messageCount: item.messageCount || 0,
|
||||||
|
projectHash: item.projectHash,
|
||||||
|
filePath: item.filePath,
|
||||||
|
cwd: item.cwd,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -282,6 +294,100 @@ export class QwenAgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session list (paged)
|
||||||
|
* Uses ACP session/list with cursor-based pagination when available.
|
||||||
|
* Falls back to file system scan with equivalent pagination semantics.
|
||||||
|
*/
|
||||||
|
async getSessionListPaged(params?: {
|
||||||
|
cursor?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<{
|
||||||
|
sessions: Array<Record<string, unknown>>;
|
||||||
|
nextCursor?: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}> {
|
||||||
|
const size = params?.size ?? 20;
|
||||||
|
const cursor = params?.cursor;
|
||||||
|
|
||||||
|
const cliContextManager = CliContextManager.getInstance();
|
||||||
|
const supportsSessionList = cliContextManager.supportsSessionList();
|
||||||
|
|
||||||
|
if (supportsSessionList) {
|
||||||
|
try {
|
||||||
|
const response = await this.connection.listSessions({
|
||||||
|
size,
|
||||||
|
...(cursor !== undefined ? { cursor } : {}),
|
||||||
|
});
|
||||||
|
// sendRequest resolves with the JSON-RPC "result" directly
|
||||||
|
const res: any = response as any;
|
||||||
|
const items: any[] = Array.isArray(res)
|
||||||
|
? res
|
||||||
|
: Array.isArray(res?.items)
|
||||||
|
? res.items
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const mapped = items.map((item) => ({
|
||||||
|
id: item.sessionId || item.id,
|
||||||
|
sessionId: item.sessionId || item.id,
|
||||||
|
title: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
|
name: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
|
startTime: item.startTime,
|
||||||
|
lastUpdated: item.mtime || item.lastUpdated,
|
||||||
|
messageCount: item.messageCount || 0,
|
||||||
|
projectHash: item.projectHash,
|
||||||
|
filePath: item.filePath,
|
||||||
|
cwd: item.cwd,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nextCursor: number | undefined = Array.isArray(res?.items)
|
||||||
|
? (res.nextCursor as number | undefined)
|
||||||
|
: undefined;
|
||||||
|
const hasMore: boolean = Array.isArray(res?.items)
|
||||||
|
? Boolean(res.hasMore)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return { sessions: mapped, nextCursor, hasMore };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[QwenAgentManager] Paged ACP session list failed:', error);
|
||||||
|
// fall through to file system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: file system for current project only (to match ACP semantics)
|
||||||
|
try {
|
||||||
|
const all = await this.sessionReader.getAllSessions(
|
||||||
|
this.currentWorkingDir,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// Sorted by lastUpdated desc already per reader
|
||||||
|
const allWithMtime = all.map((s) => ({
|
||||||
|
raw: s,
|
||||||
|
mtime: new Date(s.lastUpdated).getTime(),
|
||||||
|
}));
|
||||||
|
const filtered = cursor !== undefined
|
||||||
|
? allWithMtime.filter((x) => x.mtime < cursor)
|
||||||
|
: allWithMtime;
|
||||||
|
const page = filtered.slice(0, size);
|
||||||
|
const sessions = page.map((x) => ({
|
||||||
|
id: x.raw.sessionId,
|
||||||
|
sessionId: x.raw.sessionId,
|
||||||
|
title: this.sessionReader.getSessionTitle(x.raw),
|
||||||
|
name: this.sessionReader.getSessionTitle(x.raw),
|
||||||
|
startTime: x.raw.startTime,
|
||||||
|
lastUpdated: x.raw.lastUpdated,
|
||||||
|
messageCount: x.raw.messages.length,
|
||||||
|
projectHash: x.raw.projectHash,
|
||||||
|
}));
|
||||||
|
const nextCursorVal = page.length > 0 ? page[page.length - 1].mtime : undefined;
|
||||||
|
const hasMore = filtered.length > size;
|
||||||
|
return { sessions, nextCursor: nextCursorVal, hasMore };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[QwenAgentManager] File system paged list failed:', error);
|
||||||
|
return { sessions: [], hasMore: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get session messages (read from disk)
|
* Get session messages (read from disk)
|
||||||
*
|
*
|
||||||
@@ -290,6 +396,24 @@ export class QwenAgentManager {
|
|||||||
*/
|
*/
|
||||||
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
|
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||||
try {
|
try {
|
||||||
|
// Prefer reading CLI's JSONL if we can find filePath from session/list
|
||||||
|
const cliContextManager = CliContextManager.getInstance();
|
||||||
|
if (cliContextManager.supportsSessionList()) {
|
||||||
|
try {
|
||||||
|
const list = await this.getSessionList();
|
||||||
|
const item = list.find(
|
||||||
|
(s) => s.sessionId === sessionId || s.id === sessionId,
|
||||||
|
) as { filePath?: string } | undefined;
|
||||||
|
if (item?.filePath) {
|
||||||
|
const messages = await this.readJsonlMessages(item.filePath);
|
||||||
|
if (messages.length > 0) return messages;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[QwenAgentManager] JSONL read path lookup failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: legacy JSON session files
|
||||||
const session = await this.sessionReader.getSession(
|
const session = await this.sessionReader.getSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
this.currentWorkingDir,
|
this.currentWorkingDir,
|
||||||
@@ -297,24 +421,74 @@ export class QwenAgentManager {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return session.messages.map(
|
return session.messages.map(
|
||||||
(msg: { type: string; content: string; timestamp: string }) => ({
|
(msg: { type: string; content: string; timestamp: string }) => ({
|
||||||
role:
|
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||||
msg.type === 'user' ? ('user' as const) : ('assistant' as const),
|
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
timestamp: new Date(msg.timestamp).getTime(),
|
timestamp: new Date(msg.timestamp).getTime(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[QwenAgentManager] Failed to get session messages:', error);
|
||||||
'[QwenAgentManager] Failed to get session messages:',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read CLI JSONL session file and convert to ChatMessage[] for UI
|
||||||
|
private async readJsonlMessages(filePath: string): Promise<ChatMessage[]> {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const readline = await import('readline');
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return [];
|
||||||
|
const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
||||||
|
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||||
|
const records: Array<any> = [];
|
||||||
|
for await (const line of rl) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(trimmed);
|
||||||
|
records.push(obj);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Simple linear reconstruction: filter user/assistant and sort by timestamp
|
||||||
|
console.log('[QwenAgentManager] JSONL records read:', records.length, filePath);
|
||||||
|
const msgs = records
|
||||||
|
.filter((r) => r && (r.type === 'user' || r.type === 'assistant') && r.message)
|
||||||
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||||
|
.map((r) => ({
|
||||||
|
role: r.type === 'user' ? ('user' as const) : ('assistant' as const),
|
||||||
|
content: this.contentToText(r.message),
|
||||||
|
timestamp: new Date(r.timestamp).getTime(),
|
||||||
|
}));
|
||||||
|
console.log('[QwenAgentManager] JSONL messages reconstructed:', msgs.length);
|
||||||
|
return msgs;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[QwenAgentManager] Failed to read JSONL messages:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract plain text from Content (genai Content)
|
||||||
|
private contentToText(message: any): string {
|
||||||
|
try {
|
||||||
|
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
||||||
|
const texts: string[] = [];
|
||||||
|
for (const p of parts) {
|
||||||
|
if (typeof p?.text === 'string') {
|
||||||
|
texts.push(p.text);
|
||||||
|
} else if (typeof p?.data === 'string') {
|
||||||
|
texts.push(p.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return texts.join('\n');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save session via /chat save command
|
* Save session via /chat save command
|
||||||
* Since CLI doesn't support session/save ACP method, we send /chat save command directly
|
* Since CLI doesn't support session/save ACP method, we send /chat save command directly
|
||||||
@@ -497,7 +671,7 @@ export class QwenAgentManager {
|
|||||||
* @param sessionId - Session ID
|
* @param sessionId - Session ID
|
||||||
* @returns Load response or error
|
* @returns Load response or error
|
||||||
*/
|
*/
|
||||||
async loadSessionViaAcp(sessionId: string): Promise<unknown> {
|
async loadSessionViaAcp(sessionId: string, cwdOverride?: string): Promise<unknown> {
|
||||||
// Check if CLI supports session/load method
|
// Check if CLI supports session/load method
|
||||||
const cliContextManager = CliContextManager.getInstance();
|
const cliContextManager = CliContextManager.getInstance();
|
||||||
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
|
const supportsSessionLoad = cliContextManager.supportsSessionLoad();
|
||||||
@@ -513,7 +687,7 @@ export class QwenAgentManager {
|
|||||||
'[QwenAgentManager] Attempting session/load via ACP for session:',
|
'[QwenAgentManager] Attempting session/load via ACP for session:',
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
const response = await this.connection.loadSession(sessionId);
|
const response = await this.connection.loadSession(sessionId, cwdOverride);
|
||||||
console.log(
|
console.log(
|
||||||
'[QwenAgentManager] Session load succeeded. Response:',
|
'[QwenAgentManager] Session load succeeded. Response:',
|
||||||
JSON.stringify(response).substring(0, 200),
|
JSON.stringify(response).substring(0, 200),
|
||||||
|
|||||||
@@ -89,55 +89,11 @@ export class QwenConnectionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to restore existing session or create new session
|
// Try to restore existing session or create new session
|
||||||
|
// Note: Auto-restore on connect is disabled to avoid surprising loads
|
||||||
|
// when user opens a "New Chat" tab. Restoration is now an explicit action
|
||||||
|
// (session selector → session/load) or handled by higher-level flows.
|
||||||
let sessionRestored = false;
|
let sessionRestored = false;
|
||||||
|
|
||||||
// Try to get session from local files
|
|
||||||
console.log('[QwenAgentManager] Reading local session files...');
|
|
||||||
try {
|
|
||||||
const sessions = await sessionReader.getAllSessions(workingDir);
|
|
||||||
|
|
||||||
if (sessions.length > 0) {
|
|
||||||
console.log(
|
|
||||||
'[QwenAgentManager] Found existing sessions:',
|
|
||||||
sessions.length,
|
|
||||||
);
|
|
||||||
const lastSession = sessions[0]; // Already sorted by lastUpdated
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.switchSession(lastSession.sessionId);
|
|
||||||
console.log(
|
|
||||||
'[QwenAgentManager] Restored session:',
|
|
||||||
lastSession.sessionId,
|
|
||||||
);
|
|
||||||
sessionRestored = true;
|
|
||||||
|
|
||||||
// Save auth state after successful session restore
|
|
||||||
if (authStateManager) {
|
|
||||||
console.log(
|
|
||||||
'[QwenAgentManager] Saving auth state after successful session restore',
|
|
||||||
);
|
|
||||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
|
||||||
}
|
|
||||||
} catch (switchError) {
|
|
||||||
console.log(
|
|
||||||
'[QwenAgentManager] session/switch not supported or failed:',
|
|
||||||
switchError instanceof Error
|
|
||||||
? switchError.message
|
|
||||||
: String(switchError),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[QwenAgentManager] No existing sessions found');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
console.log(
|
|
||||||
'[QwenAgentManager] Failed to read local sessions:',
|
|
||||||
errorMessage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new session if unable to restore
|
// Create new session if unable to restore
|
||||||
if (!sessionRestored) {
|
if (!sessionRestored) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -190,9 +146,7 @@ export class QwenConnectionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log('[QwenAgentManager] Creating new session after authentication...');
|
||||||
'[QwenAgentManager] Creating new session after authentication...',
|
|
||||||
);
|
|
||||||
await this.newSessionWithRetry(
|
await this.newSessionWithRetry(
|
||||||
connection,
|
connection,
|
||||||
workingDir,
|
workingDir,
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export function registerNewCommands(
|
|||||||
disposables.push(
|
disposables.push(
|
||||||
vscode.commands.registerCommand(openNewChatTabCommand, async () => {
|
vscode.commands.registerCommand(openNewChatTabCommand, async () => {
|
||||||
const provider = createWebViewProvider();
|
const provider = createWebViewProvider();
|
||||||
|
// Suppress auto-restore for this newly created tab so it starts clean
|
||||||
|
try {
|
||||||
|
provider.suppressAutoRestoreOnce?.();
|
||||||
|
} catch {
|
||||||
|
// ignore if older provider does not implement the method
|
||||||
|
}
|
||||||
await provider.show();
|
await provider.show();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -508,6 +508,9 @@ export const App: React.FC = () => {
|
|||||||
sessionManagement.setSessionSearchQuery('');
|
sessionManagement.setSessionSearchQuery('');
|
||||||
}}
|
}}
|
||||||
onClose={() => sessionManagement.setShowSessionSelector(false)}
|
onClose={() => sessionManagement.setShowSessionSelector(false)}
|
||||||
|
hasMore={sessionManagement.hasMore}
|
||||||
|
isLoading={sessionManagement.isLoading}
|
||||||
|
onLoadMore={sessionManagement.handleLoadMoreSessions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
@@ -627,14 +630,26 @@ export const App: React.FC = () => {
|
|||||||
// );
|
// );
|
||||||
|
|
||||||
case 'in-progress-tool-call':
|
case 'in-progress-tool-call':
|
||||||
case 'completed-tool-call':
|
case 'completed-tool-call': {
|
||||||
|
const prev = allMessages[index - 1];
|
||||||
|
const next = allMessages[index + 1];
|
||||||
|
const isToolCallType = (x: unknown) =>
|
||||||
|
x &&
|
||||||
|
typeof x === 'object' &&
|
||||||
|
'type' in (x as Record<string, unknown>) &&
|
||||||
|
((x as { type: string }).type === 'in-progress-tool-call' ||
|
||||||
|
(x as { type: string }).type === 'completed-tool-call');
|
||||||
|
const isFirst = !isToolCallType(prev);
|
||||||
|
const isLast = !isToolCallType(next);
|
||||||
return (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
key={`completed-${(item.data as ToolCallData).toolCallId}`}
|
key={`completed-${(item.data as ToolCallData).toolCallId}`}
|
||||||
toolCall={item.data as ToolCallData}
|
toolCall={item.data as ToolCallData}
|
||||||
// onFileClick={handleFileClick}
|
isFirst={isFirst}
|
||||||
|
isLast={isLast}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export class WebViewProvider {
|
|||||||
private authStateManager: AuthStateManager;
|
private authStateManager: AuthStateManager;
|
||||||
private disposables: vscode.Disposable[] = [];
|
private disposables: vscode.Disposable[] = [];
|
||||||
private agentInitialized = false; // Track if agent has been initialized
|
private agentInitialized = false; // Track if agent has been initialized
|
||||||
|
// Control whether to auto-restore last session on the very first connect of this panel
|
||||||
|
private autoRestoreOnFirstConnect = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
context: vscode.ExtensionContext,
|
context: vscode.ExtensionContext,
|
||||||
@@ -240,6 +242,13 @@ export class WebViewProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress auto-restore once for this panel (used by "New Chat Tab").
|
||||||
|
*/
|
||||||
|
suppressAutoRestoreOnce(): void {
|
||||||
|
this.autoRestoreOnFirstConnect = false;
|
||||||
|
}
|
||||||
|
|
||||||
async show(): Promise<void> {
|
async show(): Promise<void> {
|
||||||
const panel = this.panelManager.getPanel();
|
const panel = this.panelManager.getPanel();
|
||||||
|
|
||||||
@@ -682,54 +691,61 @@ export class WebViewProvider {
|
|||||||
authMethod,
|
authMethod,
|
||||||
);
|
);
|
||||||
if (hasValidAuth) {
|
if (hasValidAuth) {
|
||||||
console.log(
|
const allowAutoRestore = this.autoRestoreOnFirstConnect;
|
||||||
'[WebViewProvider] Found valid cached auth, attempting session restoration',
|
// Reset for subsequent connects (only once per panel lifecycle unless set again)
|
||||||
);
|
this.autoRestoreOnFirstConnect = true;
|
||||||
|
|
||||||
|
if (allowAutoRestore) {
|
||||||
|
console.log(
|
||||||
|
'[WebViewProvider] Valid auth found, attempting auto-restore of last session...',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Try to create a session (this will use cached auth)
|
const page = await this.agentManager.getSessionListPaged({ size: 1 });
|
||||||
const sessionId = await this.agentManager.createNewSession(
|
const item = page.sessions[0] as
|
||||||
workingDir,
|
| { sessionId?: string; id?: string; cwd?: string }
|
||||||
this.authStateManager,
|
| undefined;
|
||||||
|
if (item && (item.sessionId || item.id)) {
|
||||||
|
const targetId = (item.sessionId || item.id) as string;
|
||||||
|
await this.agentManager.loadSessionViaAcp(
|
||||||
|
targetId,
|
||||||
|
(item.cwd as string | undefined) ?? workingDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sessionId) {
|
this.messageHandler.setCurrentConversationId(targetId);
|
||||||
console.log(
|
const messages = await this.agentManager.getSessionMessages(
|
||||||
'[WebViewProvider] ACP session restored successfully with ID:',
|
targetId,
|
||||||
sessionId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
'[WebViewProvider] ACP session restoration returned no session ID',
|
|
||||||
);
|
);
|
||||||
|
this.sendMessageToWebView({
|
||||||
|
type: 'qwenSessionSwitched',
|
||||||
|
data: { sessionId: targetId, messages },
|
||||||
|
});
|
||||||
|
console.log('[WebViewProvider] Auto-restored last session:', targetId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
console.log('[WebViewProvider] No sessions to auto-restore, creating new session');
|
||||||
} catch (restoreError) {
|
} catch (restoreError) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[WebViewProvider] Failed to restore ACP session:',
|
'[WebViewProvider] Auto-restore failed, will create a new session:',
|
||||||
restoreError,
|
restoreError,
|
||||||
);
|
);
|
||||||
// Clear invalid auth cache
|
}
|
||||||
await this.authStateManager.clearAuthState();
|
} else {
|
||||||
|
console.log('[WebViewProvider] Auto-restore suppressed for this panel');
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to creating a new session
|
// Create a fresh ACP session (no auto-restore or restore failed)
|
||||||
try {
|
try {
|
||||||
await this.agentManager.createNewSession(
|
await this.agentManager.createNewSession(
|
||||||
workingDir,
|
workingDir,
|
||||||
this.authStateManager,
|
this.authStateManager,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log('[WebViewProvider] ACP session created successfully');
|
||||||
'[WebViewProvider] ACP session created successfully after restore failure',
|
|
||||||
);
|
|
||||||
} catch (sessionError) {
|
} catch (sessionError) {
|
||||||
console.error(
|
console.error('[WebViewProvider] Failed to create ACP session:', sessionError);
|
||||||
'[WebViewProvider] Failed to create ACP session:',
|
|
||||||
sessionError,
|
|
||||||
);
|
|
||||||
vscode.window.showWarningMessage(
|
vscode.window.showWarningMessage(
|
||||||
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
'[WebViewProvider] No valid cached auth found, creating new session',
|
'[WebViewProvider] No valid cached auth found, creating new session',
|
||||||
|
|||||||
@@ -35,4 +35,8 @@ export type { ToolCallContent } from './toolcalls/shared/types.js';
|
|||||||
*/
|
*/
|
||||||
export const ToolCall: React.FC<{
|
export const ToolCall: React.FC<{
|
||||||
toolCall: import('./toolcalls/shared/types.js').ToolCallData;
|
toolCall: import('./toolcalls/shared/types.js').ToolCallData;
|
||||||
}> = ({ toolCall }) => <ToolCallRouter toolCall={toolCall} />;
|
isFirst?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
}> = ({ toolCall, isFirst, isLast }) => (
|
||||||
|
<ToolCallRouter toolCall={toolCall} isFirst={isFirst} isLast={isLast} />
|
||||||
|
);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ interface SessionSelectorProps {
|
|||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
onSelectSession: (sessionId: string) => void;
|
onSelectSession: (sessionId: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +34,9 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onClose,
|
onClose,
|
||||||
|
hasMore = false,
|
||||||
|
isLoading = false,
|
||||||
|
onLoadMore,
|
||||||
}) => {
|
}) => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return null;
|
return null;
|
||||||
@@ -66,7 +72,17 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Session List with Grouping */}
|
{/* 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 ? (
|
{hasNoSessions ? (
|
||||||
<div
|
<div
|
||||||
className="p-5 text-center text-[var(--app-secondary-foreground)]"
|
className="p-5 text-center text-[var(--app-secondary-foreground)]"
|
||||||
@@ -126,6 +142,11 @@ export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="p-2 text-center opacity-60 text-[0.9em]">
|
||||||
|
{isLoading ? 'Loading…' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import type { BaseToolCallProps } from '../shared/types.js';
|
import type { BaseToolCallProps } from '../shared/types.js';
|
||||||
import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
|
||||||
import {
|
import {
|
||||||
groupContent,
|
groupContent,
|
||||||
mapToolStatusToContainerStatus,
|
mapToolStatusToContainerStatus,
|
||||||
@@ -23,7 +22,11 @@ import { handleOpenDiff } from '../../../utils/diffUtils.js';
|
|||||||
* Optimized for displaying file reading operations
|
* Optimized for displaying file reading operations
|
||||||
* Shows: Read filename (no content preview)
|
* Shows: Read filename (no content preview)
|
||||||
*/
|
*/
|
||||||
export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
export const ReadToolCall: React.FC<BaseToolCallProps> = ({
|
||||||
|
toolCall,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
}) => {
|
||||||
const { content, locations, toolCallId } = toolCall;
|
const { content, locations, toolCallId } = toolCall;
|
||||||
const vscode = useVSCode();
|
const vscode = useVSCode();
|
||||||
|
|
||||||
@@ -71,76 +74,85 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
| 'loading'
|
| 'loading'
|
||||||
| 'default' = mapToolStatusToContainerStatus(toolCall.status);
|
| 'default' = mapToolStatusToContainerStatus(toolCall.status);
|
||||||
|
|
||||||
// Error case: show error
|
// Compute pseudo-element classes for status dot (use ::before per requirement)
|
||||||
if (errors.length > 0) {
|
const beforeStatusClass =
|
||||||
const path = locations?.[0]?.path || '';
|
containerStatus === 'success'
|
||||||
|
? 'before:text-qwen-success'
|
||||||
|
: containerStatus === 'error'
|
||||||
|
? 'before:text-qwen-error'
|
||||||
|
: containerStatus === 'warning'
|
||||||
|
? 'before:text-qwen-warning'
|
||||||
|
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
|
||||||
|
|
||||||
|
const ReadContainer: React.FC<{
|
||||||
|
status: typeof containerStatus;
|
||||||
|
path?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
isError?: boolean;
|
||||||
|
}> = ({ status, path, children, isError }) => {
|
||||||
|
// Adjust the connector line to crop for first/last items
|
||||||
|
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
|
||||||
|
const lineCropBottom = isLast ? 'bottom-auto h-[calc(100%-24px)]' : 'bottom-0';
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<div
|
||||||
label={'Read'}
|
className={
|
||||||
className="read-tool-call-error"
|
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
|
||||||
status="error"
|
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
|
||||||
toolCallId={toolCallId}
|
beforeStatusClass
|
||||||
labelSuffix={
|
}
|
||||||
path ? (
|
>
|
||||||
|
{/* timeline vertical line */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||||
|
Read
|
||||||
|
</span>
|
||||||
|
{path ? (
|
||||||
<FileLink
|
<FileLink
|
||||||
path={path}
|
path={path}
|
||||||
showFullPath={false}
|
showFullPath={false}
|
||||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||||
/>
|
/>
|
||||||
) : undefined
|
) : null}
|
||||||
}
|
</div>
|
||||||
|
{children ? (
|
||||||
|
<div
|
||||||
|
className={`mt-1 text-[var(--app-secondary-foreground)] ${
|
||||||
|
isError ? 'text-qwen-error' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Error case: show error
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const path = locations?.[0]?.path || '';
|
||||||
|
return (
|
||||||
|
<ReadContainer status="error" path={path} isError>
|
||||||
{errors.join('\n')}
|
{errors.join('\n')}
|
||||||
</ToolCallContainer>
|
</ReadContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success case with diff: keep UI compact; VS Code diff is auto-opened above
|
// Success case with diff: keep UI compact; VS Code diff is auto-opened above
|
||||||
if (diffs.length > 0) {
|
if (diffs.length > 0) {
|
||||||
const path = diffs[0]?.path || locations?.[0]?.path || '';
|
const path = diffs[0]?.path || locations?.[0]?.path || '';
|
||||||
return (
|
return <ReadContainer status={containerStatus} path={path} />;
|
||||||
<ToolCallContainer
|
|
||||||
label={'Read'}
|
|
||||||
className={`read-tool-call-${containerStatus}`}
|
|
||||||
status={containerStatus}
|
|
||||||
toolCallId={toolCallId}
|
|
||||||
labelSuffix={
|
|
||||||
path ? (
|
|
||||||
<FileLink
|
|
||||||
path={path}
|
|
||||||
showFullPath={false}
|
|
||||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{null}
|
|
||||||
</ToolCallContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success case: show which file was read with filename in label
|
// Success case: show which file was read with filename in label
|
||||||
if (locations && locations.length > 0) {
|
if (locations && locations.length > 0) {
|
||||||
const path = locations[0].path;
|
const path = locations[0].path;
|
||||||
return (
|
return <ReadContainer status={containerStatus} path={path} />;
|
||||||
<ToolCallContainer
|
|
||||||
label={'Read'}
|
|
||||||
className={`read-tool-call-${containerStatus}`}
|
|
||||||
status={containerStatus}
|
|
||||||
toolCallId={toolCallId}
|
|
||||||
labelSuffix={
|
|
||||||
path ? (
|
|
||||||
<FileLink
|
|
||||||
path={path}
|
|
||||||
showFullPath={false}
|
|
||||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{null}
|
|
||||||
</ToolCallContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No file info, don't show
|
// No file info, don't show
|
||||||
|
|||||||
@@ -8,12 +8,7 @@
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { BaseToolCallProps } from '../shared/types.js';
|
import type { BaseToolCallProps } from '../shared/types.js';
|
||||||
import {
|
import { FileLink } from '../../ui/FileLink.js';
|
||||||
ToolCallContainer,
|
|
||||||
ToolCallCard,
|
|
||||||
ToolCallRow,
|
|
||||||
LocationsList,
|
|
||||||
} from '../shared/LayoutComponents.js';
|
|
||||||
import {
|
import {
|
||||||
safeTitle,
|
safeTitle,
|
||||||
groupContent,
|
groupContent,
|
||||||
@@ -25,7 +20,122 @@ import {
|
|||||||
* Optimized for displaying search operations and results
|
* Optimized for displaying search operations and results
|
||||||
* Shows query + result count or file list
|
* Shows query + result count or file list
|
||||||
*/
|
*/
|
||||||
export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
// Local, scoped inline container for compact search rows (single result/text-only)
|
||||||
|
const InlineContainer: React.FC<{
|
||||||
|
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
|
||||||
|
labelSuffix?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
isFirst?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
}> = ({ status, labelSuffix, children, isFirst, isLast }) => {
|
||||||
|
const beforeStatusClass =
|
||||||
|
status === 'success'
|
||||||
|
? 'before:text-qwen-success'
|
||||||
|
: status === 'error'
|
||||||
|
? 'before:text-qwen-error'
|
||||||
|
: status === 'warning'
|
||||||
|
? 'before:text-qwen-warning'
|
||||||
|
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
|
||||||
|
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
|
||||||
|
const lineCropBottom = isLast ? 'bottom-auto h-[calc(100%-24px)]' : 'bottom-0';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
|
||||||
|
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
|
||||||
|
beforeStatusClass
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* timeline vertical line */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||||
|
Search
|
||||||
|
</span>
|
||||||
|
{labelSuffix ? (
|
||||||
|
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||||
|
{labelSuffix}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{children ? (
|
||||||
|
<div className="mt-1 text-[var(--app-secondary-foreground)]">{children}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local card layout for multi-result or error display
|
||||||
|
const SearchCard: React.FC<{
|
||||||
|
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
|
||||||
|
children: React.ReactNode;
|
||||||
|
isFirst?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
}> = ({ status, children, isFirst, isLast }) => {
|
||||||
|
const beforeStatusClass =
|
||||||
|
status === 'success'
|
||||||
|
? 'before:text-qwen-success'
|
||||||
|
: status === 'error'
|
||||||
|
? 'before:text-qwen-error'
|
||||||
|
: status === 'warning'
|
||||||
|
? 'before:text-qwen-warning'
|
||||||
|
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
|
||||||
|
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
|
||||||
|
const lineCropBottom = isLast ? 'bottom-auto h-[calc(100%-24px)]' : 'bottom-0';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
|
||||||
|
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
|
||||||
|
beforeStatusClass
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* timeline vertical line */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium">
|
||||||
|
<div className="flex flex-col gap-3 min-w-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<div className="grid grid-cols-[80px_1fr] gap-medium min-w-0">
|
||||||
|
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-[var(--app-primary-foreground)] min-w-0 break-words">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LocationsListLocal: React.FC<{
|
||||||
|
locations: Array<{ path: string; line?: number | null }>;
|
||||||
|
}> = ({ locations }) => (
|
||||||
|
<div className="flex flex-col gap-1 max-w-full">
|
||||||
|
{locations.map((loc, idx) => (
|
||||||
|
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SearchToolCall: React.FC<BaseToolCallProps> = ({
|
||||||
|
toolCall,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
}) => {
|
||||||
const { title, content, locations } = toolCall;
|
const { title, content, locations } = toolCall;
|
||||||
const queryText = safeTitle(title);
|
const queryText = safeTitle(title);
|
||||||
|
|
||||||
@@ -35,14 +145,14 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
// Error case: show search query + error in card layout
|
// Error case: show search query + error in card layout
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ToolCallCard icon="🔍">
|
<SearchCard status="error" isFirst={isFirst} isLast={isLast}>
|
||||||
<ToolCallRow label="Search">
|
<SearchRow label="Search">
|
||||||
<div className="font-mono">{queryText}</div>
|
<div className="font-mono">{queryText}</div>
|
||||||
</ToolCallRow>
|
</SearchRow>
|
||||||
<ToolCallRow label="Error">
|
<SearchRow label="Error">
|
||||||
<div className="text-[#c74e39] font-medium">{errors.join('\n')}</div>
|
<div className="text-qwen-error font-medium">{errors.join('\n')}</div>
|
||||||
</ToolCallRow>
|
</SearchRow>
|
||||||
</ToolCallCard>
|
</SearchCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,28 +162,27 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
// If multiple results, use card layout; otherwise use compact format
|
// If multiple results, use card layout; otherwise use compact format
|
||||||
if (locations.length > 1) {
|
if (locations.length > 1) {
|
||||||
return (
|
return (
|
||||||
<ToolCallCard icon="🔍">
|
<SearchCard status={containerStatus} isFirst={isFirst} isLast={isLast}>
|
||||||
<ToolCallRow label="Search">
|
<SearchRow label="Search">
|
||||||
<div className="font-mono">{queryText}</div>
|
<div className="font-mono">{queryText}</div>
|
||||||
</ToolCallRow>
|
</SearchRow>
|
||||||
<ToolCallRow label={`Found (${locations.length})`}>
|
<SearchRow label={`Found (${locations.length})`}>
|
||||||
<LocationsList locations={locations} />
|
<LocationsListLocal locations={locations} />
|
||||||
</ToolCallRow>
|
</SearchRow>
|
||||||
</ToolCallCard>
|
</SearchCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Single result - compact format
|
// Single result - compact format
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<InlineContainer
|
||||||
label="Search"
|
|
||||||
status={containerStatus}
|
status={containerStatus}
|
||||||
className="search-toolcall"
|
|
||||||
labelSuffix={`(${queryText})`}
|
labelSuffix={`(${queryText})`}
|
||||||
|
isFirst={isFirst}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
{/* <span className="font-mono">{queryText}</span> */}
|
|
||||||
<span className="mx-2 opacity-50">→</span>
|
<span className="mx-2 opacity-50">→</span>
|
||||||
<LocationsList locations={locations} />
|
<LocationsListLocal locations={locations} />
|
||||||
</ToolCallContainer>
|
</InlineContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +190,11 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
if (textOutputs.length > 0) {
|
if (textOutputs.length > 0) {
|
||||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<InlineContainer
|
||||||
label="Search"
|
|
||||||
status={containerStatus}
|
status={containerStatus}
|
||||||
className="search-toolcall"
|
|
||||||
labelSuffix={queryText ? `(${queryText})` : undefined}
|
labelSuffix={queryText ? `(${queryText})` : undefined}
|
||||||
|
isFirst={isFirst}
|
||||||
|
isLast={isLast}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{textOutputs.map((text, index) => (
|
{textOutputs.map((text, index) => (
|
||||||
@@ -98,7 +207,7 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ToolCallContainer>
|
</InlineContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,13 +215,9 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
if (queryText) {
|
if (queryText) {
|
||||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<InlineContainer status={containerStatus} isFirst={isFirst} isLast={isLast}>
|
||||||
label="Search"
|
|
||||||
status={containerStatus}
|
|
||||||
className="search-toolcall"
|
|
||||||
>
|
|
||||||
<span className="font-mono">{queryText}</span>
|
<span className="font-mono">{queryText}</span>
|
||||||
</ToolCallContainer>
|
</InlineContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ export const getToolCallComponent = (
|
|||||||
/**
|
/**
|
||||||
* Main tool call component that routes to specialized implementations
|
* Main tool call component that routes to specialized implementations
|
||||||
*/
|
*/
|
||||||
export const ToolCallRouter: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
export const ToolCallRouter: React.FC<BaseToolCallProps> = ({
|
||||||
|
toolCall,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
}) => {
|
||||||
// Check if we should show this tool call (hide internal ones)
|
// Check if we should show this tool call (hide internal ones)
|
||||||
if (!shouldShowToolCall(toolCall.kind)) {
|
if (!shouldShowToolCall(toolCall.kind)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -102,7 +106,7 @@ export const ToolCallRouter: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
const Component = getToolCallComponent(toolCall.kind, toolCall);
|
const Component = getToolCallComponent(toolCall.kind, toolCall);
|
||||||
|
|
||||||
// Render the specialized component
|
// Render the specialized component
|
||||||
return <Component toolCall={toolCall} />;
|
return <Component toolCall={toolCall} isFirst={isFirst} isLast={isLast} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||||
>
|
>
|
||||||
{/* Timeline connector line using ::after pseudo-element */}
|
<div className="toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
|
||||||
{/* TODO: gap-0 */}
|
<div className="flex items-baseline gap-1 relative min-w-0">
|
||||||
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
|
||||||
<div className="flex items-center gap-1 relative min-w-0">
|
|
||||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export interface ToolCallData {
|
|||||||
*/
|
*/
|
||||||
export interface BaseToolCallProps {
|
export interface BaseToolCallProps {
|
||||||
toolCall: ToolCallData;
|
toolCall: ToolCallData;
|
||||||
|
// Optional timeline flags for rendering connector line cropping
|
||||||
|
isFirst?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'getQwenSessions':
|
case 'getQwenSessions':
|
||||||
await this.handleGetQwenSessions();
|
await this.handleGetQwenSessions(
|
||||||
|
(data?.cursor as number | undefined) ?? undefined,
|
||||||
|
(data?.size as number | undefined) ?? undefined,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'saveSession':
|
case 'saveSession':
|
||||||
@@ -593,8 +596,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get session details
|
// Get session details (includes cwd and filePath when using ACP)
|
||||||
let sessionDetails = null;
|
let sessionDetails: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
const allSessions = await this.agentManager.getSessionList();
|
const allSessions = await this.agentManager.getSessionList();
|
||||||
sessionDetails = allSessions.find(
|
sessionDetails = allSessions.find(
|
||||||
@@ -613,8 +616,10 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
|
|
||||||
// Try to load session via ACP (now we should be connected)
|
// Try to load session via ACP (now we should be connected)
|
||||||
try {
|
try {
|
||||||
const loadResponse =
|
const loadResponse = await this.agentManager.loadSessionViaAcp(
|
||||||
await this.agentManager.loadSessionViaAcp(sessionId);
|
sessionId,
|
||||||
|
(sessionDetails?.cwd as string | undefined) || undefined,
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
'[SessionMessageHandler] session/load succeeded:',
|
'[SessionMessageHandler] session/load succeeded:',
|
||||||
loadResponse,
|
loadResponse,
|
||||||
@@ -778,12 +783,22 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
/**
|
/**
|
||||||
* Handle get Qwen sessions request
|
* Handle get Qwen sessions request
|
||||||
*/
|
*/
|
||||||
private async handleGetQwenSessions(): Promise<void> {
|
private async handleGetQwenSessions(
|
||||||
|
cursor?: number,
|
||||||
|
size?: number,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
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({
|
this.sendToWebView({
|
||||||
type: 'qwenSessionList',
|
type: 'qwenSessionList',
|
||||||
data: { sessions },
|
data: {
|
||||||
|
sessions: page.sessions,
|
||||||
|
nextCursor: page.nextCursor,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
append,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SessionMessageHandler] Failed to get sessions:', error);
|
console.error('[SessionMessageHandler] Failed to get sessions:', error);
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
|||||||
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
||||||
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
|
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
|
||||||
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
|
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
|
* Filter session list
|
||||||
@@ -44,10 +49,24 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
|||||||
* Load session list
|
* Load session list
|
||||||
*/
|
*/
|
||||||
const handleLoadQwenSessions = useCallback(() => {
|
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);
|
setShowSessionSelector(true);
|
||||||
}, [vscode]);
|
}, [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
|
* Create new session
|
||||||
*/
|
*/
|
||||||
@@ -117,6 +136,9 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
|||||||
sessionSearchQuery,
|
sessionSearchQuery,
|
||||||
filteredSessions,
|
filteredSessions,
|
||||||
savedSessionTags,
|
savedSessionTags,
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
isLoading,
|
||||||
|
|
||||||
// State setters
|
// State setters
|
||||||
setQwenSessions,
|
setQwenSessions,
|
||||||
@@ -125,6 +147,9 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
|||||||
setShowSessionSelector,
|
setShowSessionSelector,
|
||||||
setSessionSearchQuery,
|
setSessionSearchQuery,
|
||||||
setSavedSessionTags,
|
setSavedSessionTags,
|
||||||
|
setNextCursor,
|
||||||
|
setHasMore,
|
||||||
|
setIsLoading,
|
||||||
|
|
||||||
// Operations
|
// Operations
|
||||||
handleLoadQwenSessions,
|
handleLoadQwenSessions,
|
||||||
@@ -132,5 +157,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
|||||||
handleSwitchSession,
|
handleSwitchSession,
|
||||||
handleSaveSession,
|
handleSaveSession,
|
||||||
handleSaveSessionResponse,
|
handleSaveSessionResponse,
|
||||||
|
handleLoadMoreSessions,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ interface UseWebViewMessagesProps {
|
|||||||
// Session management
|
// Session management
|
||||||
sessionManagement: {
|
sessionManagement: {
|
||||||
currentSessionId: string | null;
|
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;
|
setCurrentSessionId: (id: string | null) => void;
|
||||||
setCurrentSessionTitle: (title: string) => void;
|
setCurrentSessionTitle: (title: string) => void;
|
||||||
setShowSessionSelector: (show: boolean) => void;
|
setShowSessionSelector: (show: boolean) => void;
|
||||||
|
setNextCursor: (cursor: number | undefined) => void;
|
||||||
|
setHasMore: (hasMore: boolean) => void;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
handleSaveSessionResponse: (response: {
|
handleSaveSessionResponse: (response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -487,8 +494,17 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'qwenSessionList': {
|
case 'qwenSessionList': {
|
||||||
const sessions = message.data.sessions || [];
|
const sessions = (message.data.sessions as any[]) || [];
|
||||||
handlers.sessionManagement.setQwenSessions(sessions);
|
const append = Boolean(message.data.append);
|
||||||
|
const nextCursor = message.data.nextCursor as number | undefined;
|
||||||
|
const hasMore = Boolean(message.data.hasMore);
|
||||||
|
|
||||||
|
handlers.sessionManagement.setQwenSessions((prev: any[]) =>
|
||||||
|
append ? [...prev, ...sessions] : sessions,
|
||||||
|
);
|
||||||
|
handlers.sessionManagement.setNextCursor(nextCursor);
|
||||||
|
handlers.sessionManagement.setHasMore(hasMore);
|
||||||
|
handlers.sessionManagement.setIsLoading(false);
|
||||||
if (
|
if (
|
||||||
handlers.sessionManagement.currentSessionId &&
|
handlers.sessionManagement.currentSessionId &&
|
||||||
sessions.length > 0
|
sessions.length > 0
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export default {
|
|||||||
ivory: '#f5f5ff',
|
ivory: '#f5f5ff',
|
||||||
slate: '#141420',
|
slate: '#141420',
|
||||||
green: '#6bcf7f',
|
green: '#6bcf7f',
|
||||||
|
// Status colors used by toolcall components
|
||||||
|
success: '#74c991',
|
||||||
|
error: '#c74e39',
|
||||||
|
warning: '#e1c08d',
|
||||||
|
loading: 'var(--app-secondary-foreground)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
|
|||||||
Reference in New Issue
Block a user