From 57a684ad97fbd8312100d263365124bf13d2bed3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 6 Dec 2025 16:53:40 +0800 Subject: [PATCH] WIP: All changes including session and toolcall improvements --- .../src/acp/acpConnection.ts | 11 +- .../src/acp/acpSessionManager.ts | 17 +- .../src/agents/qwenAgentManager.ts | 212 ++++++++++++++++-- .../src/agents/qwenConnectionHandler.ts | 54 +---- .../src/commands/index.ts | 6 + .../vscode-ide-companion/src/webview/App.tsx | 19 +- .../src/webview/WebViewProvider.ts | 98 ++++---- .../src/webview/components/ToolCall.tsx | 6 +- .../components/session/SessionSelector.tsx | 23 +- .../toolcalls/Read/ReadToolCall.tsx | 124 +++++----- .../toolcalls/Search/SearchToolCall.tsx | 179 ++++++++++++--- .../webview/components/toolcalls/index.tsx | 8 +- .../toolcalls/shared/LayoutComponents.tsx | 6 +- .../components/toolcalls/shared/types.ts | 3 + .../webview/handlers/SessionMessageHandler.ts | 31 ++- .../hooks/session/useSessionManagement.ts | 28 ++- .../src/webview/hooks/useWebViewMessages.ts | 22 +- .../vscode-ide-companion/tailwind.config.js | 5 + 18 files changed, 622 insertions(+), 230 deletions(-) diff --git a/packages/vscode-ide-companion/src/acp/acpConnection.ts b/packages/vscode-ide-companion/src/acp/acpConnection.ts index 27a111c6..b3ddf2a0 100644 --- a/packages/vscode-ide-companion/src/acp/acpConnection.ts +++ b/packages/vscode-ide-companion/src/acp/acpConnection.ts @@ -32,6 +32,9 @@ export class AcpConnection { private pendingRequests = new Map>(); private nextRequestId = { value: 0 }; 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 sessionManager: AcpSessionManager; @@ -66,6 +69,7 @@ export class AcpConnection { } this.backend = backend; + this.workingDir = workingDir; const isWindows = process.platform === 'win32'; const env = { ...process.env }; @@ -310,12 +314,13 @@ export class AcpConnection { * @param sessionId - Session ID * @returns Load response */ - async loadSession(sessionId: string): Promise { + async loadSession(sessionId: string, cwdOverride?: string): Promise { return this.sessionManager.loadSession( sessionId, this.child, this.pendingRequests, this.nextRequestId, + cwdOverride || this.workingDir, ); } @@ -324,11 +329,13 @@ export class AcpConnection { * * @returns Session list response */ - async listSessions(): Promise { + async listSessions(options?: { cursor?: number; size?: number }): Promise { return this.sessionManager.listSessions( this.child, this.pendingRequests, this.nextRequestId, + this.workingDir, + options, ); } diff --git a/packages/vscode-ide-companion/src/acp/acpSessionManager.ts b/packages/vscode-ide-companion/src/acp/acpSessionManager.ts index efe82331..db590053 100644 --- a/packages/vscode-ide-companion/src/acp/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/acp/acpSessionManager.ts @@ -247,11 +247,12 @@ export class AcpSessionManager { child: ChildProcess | null, pendingRequests: Map>, nextRequestId: { value: number }, + cwd: string = process.cwd(), ): Promise { console.log('[ACP] Sending session/load request for session:', sessionId); console.log('[ACP] Request parameters:', { sessionId, - cwd: process.cwd(), + cwd, mcpServers: [], }); @@ -260,7 +261,7 @@ export class AcpSessionManager { AGENT_METHODS.session_load, { sessionId, - cwd: process.cwd(), + cwd, mcpServers: [], }, child, @@ -278,6 +279,9 @@ export class AcpSessionManager { console.error('[ACP] Session load returned error:', response.error); } else { 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; @@ -302,12 +306,19 @@ export class AcpSessionManager { child: ChildProcess | null, pendingRequests: Map>, nextRequestId: { value: number }, + cwd: string = process.cwd(), + options?: { cursor?: number; size?: number }, ): Promise { console.log('[ACP] Requesting session list...'); try { + // session/list requires cwd in params per ACP schema + const params: Record = { cwd }; + if (options?.cursor !== undefined) params.cursor = options.cursor; + if (options?.size !== undefined) params.size = options.size; + const response = await this.sendRequest( AGENT_METHODS.session_list, - {}, + params, child, pendingRequests, nextRequestId, diff --git a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts index bed783a8..c7f020bf 100644 --- a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts @@ -220,16 +220,28 @@ export class QwenAgentManager { const response = await this.connection.listSessions(); console.log('[QwenAgentManager] ACP session list response:', response); - if (response.result && Array.isArray(response.result)) { - const sessions = response.result.map((session) => ({ - id: session.sessionId || session.id, - sessionId: session.sessionId || session.id, - title: session.title || session.name || 'Untitled Session', - name: session.title || session.name || 'Untitled Session', - startTime: session.startTime, - lastUpdated: session.lastUpdated, - messageCount: session.messageCount || 0, - projectHash: session.projectHash, + // sendRequest resolves with the JSON-RPC "result" directly + // Newer CLI returns an object: { items: [...], nextCursor?, hasMore } + // Older prototypes might return an array. Support both. + const res: any = response as any; + const items: any[] = Array.isArray(res) + ? res + : Array.isArray(res?.items) + ? res.items + : []; + + 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( @@ -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>; + 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) * @@ -290,6 +396,24 @@ export class QwenAgentManager { */ async getSessionMessages(sessionId: string): Promise { 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( sessionId, this.currentWorkingDir, @@ -297,24 +421,74 @@ export class QwenAgentManager { if (!session) { return []; } - return session.messages.map( (msg: { type: string; content: string; timestamp: string }) => ({ - role: - msg.type === 'user' ? ('user' as const) : ('assistant' as const), + role: msg.type === 'user' ? 'user' : 'assistant', content: msg.content, timestamp: new Date(msg.timestamp).getTime(), }), ); } catch (error) { - console.error( - '[QwenAgentManager] Failed to get session messages:', - error, - ); + console.error('[QwenAgentManager] Failed to get session messages:', error); return []; } } + // Read CLI JSONL session file and convert to ChatMessage[] for UI + private async readJsonlMessages(filePath: string): Promise { + 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 = []; + 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 * 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 * @returns Load response or error */ - async loadSessionViaAcp(sessionId: string): Promise { + async loadSessionViaAcp(sessionId: string, cwdOverride?: string): Promise { // Check if CLI supports session/load method const cliContextManager = CliContextManager.getInstance(); const supportsSessionLoad = cliContextManager.supportsSessionLoad(); @@ -513,7 +687,7 @@ export class QwenAgentManager { '[QwenAgentManager] Attempting session/load via ACP for session:', sessionId, ); - const response = await this.connection.loadSession(sessionId); + const response = await this.connection.loadSession(sessionId, cwdOverride); console.log( '[QwenAgentManager] Session load succeeded. Response:', JSON.stringify(response).substring(0, 200), diff --git a/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts index 5e372ab8..f0d937b2 100644 --- a/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/agents/qwenConnectionHandler.ts @@ -89,55 +89,11 @@ export class QwenConnectionHandler { } // 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; - // 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 if (!sessionRestored) { console.log( @@ -190,9 +146,7 @@ export class QwenConnectionHandler { } try { - console.log( - '[QwenAgentManager] Creating new session after authentication...', - ); + console.log('[QwenAgentManager] Creating new session after authentication...'); await this.newSessionWithRetry( connection, workingDir, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 7ca510a7..e8830e11 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -73,6 +73,12 @@ export function registerNewCommands( disposables.push( vscode.commands.registerCommand(openNewChatTabCommand, async () => { 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(); }), ); diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index f4b853be..30bf5900 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -508,6 +508,9 @@ export const App: React.FC = () => { sessionManagement.setSessionSearchQuery(''); }} onClose={() => sessionManagement.setShowSessionSelector(false)} + hasMore={sessionManagement.hasMore} + isLoading={sessionManagement.isLoading} + onLoadMore={sessionManagement.handleLoadMoreSessions} /> { // ); 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) && + ((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 ( ); + } default: return null; diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index f7b52d5c..b869761a 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -26,6 +26,8 @@ export class WebViewProvider { private authStateManager: AuthStateManager; private disposables: vscode.Disposable[] = []; 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( 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 { const panel = this.panelManager.getPanel(); @@ -682,53 +691,60 @@ export class WebViewProvider { authMethod, ); if (hasValidAuth) { - console.log( - '[WebViewProvider] Found valid cached auth, attempting session restoration', - ); + const allowAutoRestore = this.autoRestoreOnFirstConnect; + // 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 { + const page = await this.agentManager.getSessionListPaged({ size: 1 }); + const item = page.sessions[0] as + | { sessionId?: string; id?: string; cwd?: string } + | 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, + ); + + this.messageHandler.setCurrentConversationId(targetId); + const messages = await this.agentManager.getSessionMessages( + targetId, + ); + 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) { + console.warn( + '[WebViewProvider] Auto-restore failed, will create a new session:', + restoreError, + ); + } + } else { + console.log('[WebViewProvider] Auto-restore suppressed for this panel'); + } + + // Create a fresh ACP session (no auto-restore or restore failed) try { - // Try to create a session (this will use cached auth) - const sessionId = await this.agentManager.createNewSession( + await this.agentManager.createNewSession( workingDir, this.authStateManager, ); - - if (sessionId) { - console.log( - '[WebViewProvider] ACP session restored successfully with ID:', - sessionId, - ); - } else { - console.log( - '[WebViewProvider] ACP session restoration returned no session ID', - ); - } - } catch (restoreError) { - console.warn( - '[WebViewProvider] Failed to restore ACP session:', - restoreError, + console.log('[WebViewProvider] ACP session created successfully'); + } catch (sessionError) { + console.error('[WebViewProvider] Failed to create ACP session:', sessionError); + vscode.window.showWarningMessage( + `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, ); - // Clear invalid auth cache - await this.authStateManager.clearAuthState(); - - // Fall back to creating a new session - try { - await this.agentManager.createNewSession( - workingDir, - this.authStateManager, - ); - console.log( - '[WebViewProvider] ACP session created successfully after restore failure', - ); - } catch (sessionError) { - console.error( - '[WebViewProvider] Failed to create ACP session:', - sessionError, - ); - vscode.window.showWarningMessage( - `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, - ); - } } } else { console.log( diff --git a/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx index 2f6c60df..36a18368 100644 --- a/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx @@ -35,4 +35,8 @@ export type { ToolCallContent } from './toolcalls/shared/types.js'; */ export const ToolCall: React.FC<{ toolCall: import('./toolcalls/shared/types.js').ToolCallData; -}> = ({ toolCall }) => ; + isFirst?: boolean; + isLast?: boolean; +}> = ({ toolCall, isFirst, isLast }) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/session/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/session/SessionSelector.tsx index 109aa2fa..ab7f6d51 100644 --- a/packages/vscode-ide-companion/src/webview/components/session/SessionSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/session/SessionSelector.tsx @@ -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 = ({ onSearchChange, onSelectSession, onClose, + hasMore = false, + isLoading = false, + onLoadMore, }) => { if (!visible) { return null; @@ -66,7 +72,17 @@ export const SessionSelector: React.FC = ({ {/* Session List with Grouping */} -
+
{ + const el = e.currentTarget; + const distanceToBottom = + el.scrollHeight - (el.scrollTop + el.clientHeight); + if (distanceToBottom < 48 && hasMore && !isLoading) { + onLoadMore?.(); + } + }} + > {hasNoSessions ? (
= ({ )) )} + {hasMore && ( +
+ {isLoading ? 'Loading…' : ''} +
+ )}
diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/Read/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/Read/ReadToolCall.tsx index 3892bc00..a2c3d032 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/Read/ReadToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/Read/ReadToolCall.tsx @@ -9,7 +9,6 @@ import type React from 'react'; import { useCallback, useEffect, useMemo } from 'react'; import type { BaseToolCallProps } from '../shared/types.js'; -import { ToolCallContainer } from '../shared/LayoutComponents.js'; import { groupContent, mapToolStatusToContainerStatus, @@ -23,7 +22,11 @@ import { handleOpenDiff } from '../../../utils/diffUtils.js'; * Optimized for displaying file reading operations * Shows: Read filename (no content preview) */ -export const ReadToolCall: React.FC = ({ toolCall }) => { +export const ReadToolCall: React.FC = ({ + toolCall, + isFirst, + isLast, +}) => { const { content, locations, toolCallId } = toolCall; const vscode = useVSCode(); @@ -71,76 +74,85 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { | 'loading' | 'default' = mapToolStatusToContainerStatus(toolCall.status); + // Compute pseudo-element classes for status dot (use ::before per requirement) + const beforeStatusClass = + 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 ( +
+ {/* timeline vertical line */} +
+
+
+ + Read + + {path ? ( + + ) : null} +
+ {children ? ( +
+ {children} +
+ ) : null} +
+
+ ); + }; + // Error case: show error if (errors.length > 0) { const path = locations?.[0]?.path || ''; return ( - - ) : undefined - } - > + {errors.join('\n')} - + ); } // Success case with diff: keep UI compact; VS Code diff is auto-opened above if (diffs.length > 0) { const path = diffs[0]?.path || locations?.[0]?.path || ''; - return ( - - ) : undefined - } - > - {null} - - ); + return ; } // Success case: show which file was read with filename in label if (locations && locations.length > 0) { const path = locations[0].path; - return ( - - ) : undefined - } - > - {null} - - ); + return ; } // No file info, don't show diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/Search/SearchToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/Search/SearchToolCall.tsx index b9fe6f35..3a803893 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/Search/SearchToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/Search/SearchToolCall.tsx @@ -8,12 +8,7 @@ import type React from 'react'; import type { BaseToolCallProps } from '../shared/types.js'; -import { - ToolCallContainer, - ToolCallCard, - ToolCallRow, - LocationsList, -} from '../shared/LayoutComponents.js'; +import { FileLink } from '../../ui/FileLink.js'; import { safeTitle, groupContent, @@ -25,7 +20,122 @@ import { * Optimized for displaying search operations and results * Shows query + result count or file list */ -export const SearchToolCall: React.FC = ({ 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 ( +
+ {/* timeline vertical line */} +
+
+
+ + Search + + {labelSuffix ? ( + + {labelSuffix} + + ) : null} +
+ {children ? ( +
{children}
+ ) : null} +
+
+ ); +}; + +// 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 ( +
+ {/* timeline vertical line */} +
+
+
{children}
+
+
+ ); +}; + +const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +const LocationsListLocal: React.FC<{ + locations: Array<{ path: string; line?: number | null }>; +}> = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); + +export const SearchToolCall: React.FC = ({ + toolCall, + isFirst, + isLast, +}) => { const { title, content, locations } = toolCall; const queryText = safeTitle(title); @@ -35,14 +145,14 @@ export const SearchToolCall: React.FC = ({ toolCall }) => { // Error case: show search query + error in card layout if (errors.length > 0) { return ( - - + +
{queryText}
-
- -
{errors.join('\n')}
-
-
+ + +
{errors.join('\n')}
+
+ ); } @@ -52,28 +162,27 @@ export const SearchToolCall: React.FC = ({ toolCall }) => { // If multiple results, use card layout; otherwise use compact format if (locations.length > 1) { return ( - - + +
{queryText}
-
- - - -
+ + + + + ); } // Single result - compact format return ( - - {/* {queryText} */} - - + + ); } @@ -81,11 +190,11 @@ export const SearchToolCall: React.FC = ({ toolCall }) => { if (textOutputs.length > 0) { const containerStatus = mapToolStatusToContainerStatus(toolCall.status); return ( -
{textOutputs.map((text, index) => ( @@ -98,7 +207,7 @@ export const SearchToolCall: React.FC = ({ toolCall }) => {
))}
- + ); } @@ -106,13 +215,9 @@ export const SearchToolCall: React.FC = ({ toolCall }) => { if (queryText) { const containerStatus = mapToolStatusToContainerStatus(toolCall.status); return ( - + {queryText} - + ); } diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx index 37350a20..51e334b5 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx @@ -92,7 +92,11 @@ export const getToolCallComponent = ( /** * Main tool call component that routes to specialized implementations */ -export const ToolCallRouter: React.FC = ({ toolCall }) => { +export const ToolCallRouter: React.FC = ({ + toolCall, + isFirst, + isLast, +}) => { // Check if we should show this tool call (hide internal ones) if (!shouldShowToolCall(toolCall.kind)) { return null; @@ -102,7 +106,7 @@ export const ToolCallRouter: React.FC = ({ toolCall }) => { const Component = getToolCallComponent(toolCall.kind, toolCall); // Render the specialized component - return ; + return ; }; // Re-export types for convenience diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx index d4668287..bf6b2cfa 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx @@ -47,10 +47,8 @@ export const ToolCallContainer: React.FC = ({
- {/* Timeline connector line using ::after pseudo-element */} - {/* TODO: gap-0 */} -
-
+
+
{label} diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts index d0866d21..0fccb186 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts @@ -56,6 +56,9 @@ export interface ToolCallData { */ export interface BaseToolCallProps { toolCall: ToolCallData; + // Optional timeline flags for rendering connector line cropping + isFirst?: boolean; + isLast?: boolean; } /** diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index ccde5337..dc33f7ff 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -74,7 +74,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': @@ -593,8 +596,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 | null = null; try { const allSessions = await this.agentManager.getSessionList(); sessionDetails = allSessions.find( @@ -613,8 +616,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, @@ -778,12 +783,22 @@ export class SessionMessageHandler extends BaseMessageHandler { /** * Handle get Qwen sessions request */ - private async handleGetQwenSessions(): Promise { + private async handleGetQwenSessions( + cursor?: number, + size?: number, + ): Promise { 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); diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts index 47669f6a..63458855 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -21,6 +21,11 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { const [showSessionSelector, setShowSessionSelector] = useState(false); const [sessionSearchQuery, setSessionSearchQuery] = useState(''); const [savedSessionTags, setSavedSessionTags] = useState([]); + const [nextCursor, setNextCursor] = useState(undefined); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(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, }; }; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 53ed7468..e3954d55 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -18,10 +18,17 @@ interface UseWebViewMessagesProps { // Session management sessionManagement: { currentSessionId: string | null; - setQwenSessions: (sessions: Array>) => void; + setQwenSessions: ( + sessions: + | Array> + | ((prev: Array>) => Array>), + ) => 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 +494,17 @@ export const useWebViewMessages = ({ } case 'qwenSessionList': { - const sessions = message.data.sessions || []; - handlers.sessionManagement.setQwenSessions(sessions); + const sessions = (message.data.sessions as any[]) || []; + 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 ( handlers.sessionManagement.currentSessionId && sessions.length > 0 diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js index 26e28794..ee07b39d 100644 --- a/packages/vscode-ide-companion/tailwind.config.js +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -52,6 +52,11 @@ export default { ivory: '#f5f5ff', slate: '#141420', green: '#6bcf7f', + // Status colors used by toolcall components + success: '#74c991', + error: '#c74e39', + warning: '#e1c08d', + loading: 'var(--app-secondary-foreground)', }, }, borderRadius: {