feat(vscode-ide-companion): implement session message handling and UI improvements

Complete session message handling with JSONL support and UI enhancements

- Add JSONL session file reading capability

- Improve error handling and authentication flows

- Update UI components for better user experience

- Fix command identifier references

- Enhance MarkdownRenderer with copy functionality

- Update Tailwind configuration for better component coverage
This commit is contained in:
yiliang114
2025-12-06 21:46:14 +08:00
parent ad79b9bcab
commit 7cd26f728d
21 changed files with 1190 additions and 785 deletions

View File

@@ -9,6 +9,7 @@ import React, {
useEffect,
useRef,
useCallback,
useMemo,
useLayoutEffect,
} from 'react';
import { useVSCode } from './hooks/useVSCode.js';
@@ -21,15 +22,13 @@ import { useMessageSubmit } from './hooks/useMessageSubmit.js';
import type {
PermissionOption,
ToolCall as PermissionToolCall,
} from './components/PermissionRequest.js';
} from './components/PermissionDrawer/PermissionRequest.js';
import type { TextMessage } from './hooks/message/useMessageHandling.js';
import type { ToolCallData } from './components/ToolCall.js';
import { PermissionDrawer } from './components/PermissionDrawer.js';
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
import { ToolCall } from './components/ToolCall.js';
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
// import { InProgressToolCall } from './components/InProgressToolCall.js';
import { EmptyState } from './components/ui/EmptyState.js';
import type { PlanEntry } from './components/PlanDisplay.js';
import { type CompletionItem } from './types/CompletionTypes.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
import { InfoBanner } from './components/ui/InfoBanner.js';
@@ -45,6 +44,7 @@ import { InputForm } from './components/InputForm.js';
import { SessionSelector } from './components/session/SessionSelector.js';
import { FileIcon, UserIcon } from './components/icons/index.js';
import type { EditMode } from './types/toolCall.js';
import type { PlanEntry } from '../agents/qwenTypes.js';
export const App: React.FC = () => {
const vscode = useVSCode();
@@ -488,12 +488,138 @@ export const App: React.FC = () => {
setThinkingEnabled((prev) => !prev);
};
// Create unified message array containing all types of messages and tool calls
const allMessages = useMemo<
Array<{
type: 'message' | 'in-progress-tool-call' | 'completed-tool-call';
data: TextMessage | ToolCallData;
timestamp: number;
}>
>(() => {
// Regular messages
const regularMessages = messageHandling.messages.map((msg) => ({
type: 'message' as const,
data: msg,
timestamp: msg.timestamp,
}));
// In-progress tool calls
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
type: 'in-progress-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp || Date.now(),
}));
// Completed tool calls
const completedTools = completedToolCalls
.filter(hasToolCallOutput)
.map((toolCall) => ({
type: 'completed-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp || Date.now(),
}));
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
return [...regularMessages, ...inProgressTools, ...completedTools].sort(
(a, b) => (a.timestamp || 0) - (b.timestamp || 0),
);
}, [messageHandling.messages, inProgressToolCalls, completedToolCalls]);
console.log('[App] Rendering messages:', allMessages);
// Render all messages and tool calls
const renderMessages = useCallback<() => React.ReactNode>(
() =>
allMessages.map((item, index) => {
switch (item.type) {
case 'message': {
const msg = item.data as TextMessage;
const handleFileClick = (path: string): void => {
vscode.postMessage({
type: 'openFile',
data: { path },
});
};
if (msg.role === 'thinking') {
return (
<ThinkingMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
);
}
if (msg.role === 'user') {
return (
<UserMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
fileContext={msg.fileContext}
/>
);
}
{
const content = (msg.content || '').trim();
if (content === 'Interrupted' || content === 'Tool interrupted') {
return (
<InterruptedMessage key={`message-${index}`} text={content} />
);
}
return (
<AssistantMessage
key={`message-${index}`}
content={content}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
);
}
}
case 'in-progress-tool-call':
case 'completed-tool-call': {
const prev = allMessages[index - 1];
const next = allMessages[index + 1];
const isToolCallType = (
x: unknown,
): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } =>
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 (
<ToolCall
key={`toolcall-${(item.data as ToolCallData).toolCallId}-${item.type}`}
toolCall={item.data as ToolCallData}
isFirst={isFirst}
isLast={isLast}
/>
);
}
default:
return null;
}
}),
[allMessages, vscode],
);
const hasContent =
messageHandling.messages.length > 0 ||
messageHandling.isStreaming ||
inProgressToolCalls.length > 0 ||
completedToolCalls.length > 0 ||
planEntries.length > 0;
planEntries.length > 0 ||
allMessages.length > 0;
return (
<div className="chat-container">
@@ -508,6 +634,9 @@ export const App: React.FC = () => {
sessionManagement.setSessionSearchQuery('');
}}
onClose={() => sessionManagement.setShowSessionSelector(false)}
hasMore={sessionManagement.hasMore}
isLoading={sessionManagement.isLoading}
onLoadMore={sessionManagement.handleLoadMoreSessions}
/>
<ChatHeader
@@ -525,122 +654,8 @@ export const App: React.FC = () => {
<EmptyState />
) : (
<>
{/* Create unified message array containing all types of messages and tool calls */}
{(() => {
// Regular messages
const regularMessages = messageHandling.messages.map((msg) => ({
type: 'message' as const,
data: msg,
timestamp: msg.timestamp,
}));
// In-progress tool calls
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
type: 'in-progress-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp || Date.now(),
}));
// Completed tool calls
const completedTools = completedToolCalls
.filter(hasToolCallOutput)
.map((toolCall) => ({
type: 'completed-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp || Date.now(),
}));
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
const allMessages = [
...regularMessages,
...inProgressTools,
...completedTools,
].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
console.log('[App] allMessages:', allMessages);
return allMessages.map((item, index) => {
switch (item.type) {
case 'message': {
const msg = item.data as TextMessage;
const handleFileClick = (path: string) => {
vscode.postMessage({
type: 'openFile',
data: { path },
});
};
if (msg.role === 'thinking') {
return (
<ThinkingMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
);
}
if (msg.role === 'user') {
return (
<UserMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
fileContext={msg.fileContext}
/>
);
}
{
const content = (msg.content || '').trim();
if (
content === 'Interrupted' ||
content === 'Tool interrupted'
) {
return (
<InterruptedMessage
key={`message-${index}`}
text={content}
/>
);
}
return (
<AssistantMessage
key={`message-${index}`}
content={content}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
);
}
}
// case 'in-progress-tool-call':
// return (
// <InProgressToolCall
// key={`in-progress-${(item.data as ToolCallData).toolCallId}`}
// toolCall={item.data as ToolCallData}
// // onFileClick={handleFileClick}
// />
// );
case 'in-progress-tool-call':
case 'completed-tool-call':
return (
<ToolCall
key={`completed-${(item.data as ToolCallData).toolCallId}`}
toolCall={item.data as ToolCallData}
// onFileClick={handleFileClick}
/>
);
default:
return null;
}
});
})()}
{/* Render all messages and tool calls */}
{renderMessages()}
{/* Changed to push each plan as a historical toolcall in useWebViewMessages to avoid duplicate display of the latest block */}

View File

@@ -16,6 +16,7 @@ import { WebViewContent } from '../webview/WebViewContent.js';
import { CliInstaller } from '../cli/cliInstaller.js';
import { getFileName } from './utils/webviewUtils.js';
import { authMethod } from '../auth/index.js';
import { runQwenCodeCommand } from '../commands/index.js';
export class WebViewProvider {
private panelManager: PanelManager;
@@ -25,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,
@@ -239,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> {
const panel = this.panelManager.getPanel();
@@ -587,6 +597,14 @@ export class WebViewProvider {
'[WebViewProvider] Force re-login completed successfully',
);
// Ensure auth state is saved after successful re-login
if (this.authStateManager) {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
await this.authStateManager.saveAuthState(workingDir, authMethod);
console.log('[WebViewProvider] Auth state saved after re-login');
}
// Send success notification to WebView
this.sendMessageToWebView({
type: 'loginSuccess',
@@ -681,53 +699,139 @@ 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);
// Even if messages array is empty, we should still switch to the session
// This ensures we don't lose the session context
this.sendMessageToWebView({
type: 'qwenSessionSwitched',
data: { sessionId: targetId, messages },
});
console.log(
'[WebViewProvider] Auto-restored last session:',
targetId,
);
// Ensure auth state is saved after successful session restore
if (this.authStateManager) {
await this.authStateManager.saveAuthState(
workingDir,
authMethod,
);
console.log(
'[WebViewProvider] Auth state saved after session restore',
);
}
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,
);
// Try to get session messages anyway, even if loadSessionViaAcp failed
// This can happen if the session exists locally but failed to load in the CLI
try {
const page = await this.agentManager.getSessionListPaged({
size: 1,
});
const item = page.sessions[0] as
| { sessionId?: string; id?: string }
| undefined;
if (item && (item.sessionId || item.id)) {
const targetId = (item.sessionId || item.id) as string;
const messages =
await this.agentManager.getSessionMessages(targetId);
// Switch to the session with whatever messages we could get
this.messageHandler.setCurrentConversationId(targetId);
this.sendMessageToWebView({
type: 'qwenSessionSwitched',
data: { sessionId: targetId, messages },
});
console.log(
'[WebViewProvider] Partially restored last session:',
targetId,
);
// Ensure auth state is saved after partial session restore
if (this.authStateManager) {
await this.authStateManager.saveAuthState(
workingDir,
authMethod,
);
console.log(
'[WebViewProvider] Auth state saved after partial session restore',
);
}
return;
}
} catch (fallbackError) {
console.warn(
'[WebViewProvider] Fallback session restore also failed:',
fallbackError,
);
}
}
} 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,
);
console.log('[WebViewProvider] ACP session created successfully');
if (sessionId) {
// Ensure auth state is saved after successful session creation
if (this.authStateManager) {
await this.authStateManager.saveAuthState(workingDir, authMethod);
console.log(
'[WebViewProvider] ACP session restored successfully with ID:',
sessionId,
);
} else {
console.log(
'[WebViewProvider] ACP session restoration returned no session ID',
'[WebViewProvider] Auth state saved after session creation',
);
}
} catch (restoreError) {
console.warn(
'[WebViewProvider] Failed to restore ACP session:',
restoreError,
} 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(
@@ -1067,7 +1171,7 @@ export class WebViewProvider {
if (useTerminal) {
// In terminal mode, execute the runQwenCode command to open a new terminal
try {
await vscode.commands.executeCommand('qwen-code.runQwenCode');
await vscode.commands.executeCommand(runQwenCodeCommand);
console.log('[WebViewProvider] Opened new terminal session');
} catch (error) {
console.error(

View File

@@ -1,197 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* In-progress tool call component - displays active tool calls with Claude Code style
*/
import React from 'react';
import type { ToolCallData } from './toolcalls/shared/types.js';
import { FileLink } from './ui/FileLink.js';
import { useVSCode } from '../hooks/useVSCode.js';
import { handleOpenDiff } from '../utils/diffUtils.js';
interface InProgressToolCallProps {
toolCall: ToolCallData;
onFileClick?: (path: string, line?: number | null) => void;
}
/**
* Format the kind name to a readable label
*/
const formatKind = (kind: string): string => {
const kindMap: Record<string, string> = {
read: 'Read',
write: 'Write',
edit: 'Edit',
execute: 'Execute',
bash: 'Execute',
command: 'Execute',
search: 'Search',
grep: 'Search',
glob: 'Search',
find: 'Search',
think: 'Think',
thinking: 'Think',
fetch: 'Fetch',
delete: 'Delete',
move: 'Move',
};
return kindMap[kind.toLowerCase()] || 'Tool Call';
};
/**
* Get file name from path
*/
const getFileName = (path: string): string => path.split('/').pop() || path;
/**
* Component to display in-progress tool calls with Claude Code styling
* Shows kind, file name, and file locations
*/
export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
toolCall,
onFileClick: _onFileClick,
}) => {
const { kind, title, locations, content } = toolCall;
const vscode = useVSCode();
// Format the kind label
const kindLabel = formatKind(kind);
// Map tool kind to a Tailwind text color class (Claude-like palette)
const kindColorClass = React.useMemo(() => {
const k = kind.toLowerCase();
if (k === 'read') {
return 'text-[#4ec9b0]';
}
if (k === 'write' || k === 'edit') {
return 'text-[#e5c07b]';
}
if (k === 'execute' || k === 'bash' || k === 'command') {
return 'text-[#c678dd]';
}
if (k === 'search' || k === 'grep' || k === 'glob' || k === 'find') {
return 'text-[#61afef]';
}
if (k === 'think' || k === 'thinking') {
return 'text-[#98c379]';
}
return 'text-[var(--app-primary-foreground)]';
}, [kind]);
// Get file name from locations or title
let fileName: string | null = null;
let filePath: string | null = null;
let fileLine: number | null = null;
if (locations && locations.length > 0) {
fileName = getFileName(locations[0].path);
filePath = locations[0].path;
fileLine = locations[0].line || null;
} else if (typeof title === 'string') {
fileName = title;
}
// Extract content text from content array
let contentText: string | null = null;
// Extract first diff (if present)
let diffData: {
path?: string;
oldText?: string | null;
newText?: string;
} | null = null;
if (content && content.length > 0) {
// Look for text content
for (const item of content) {
if (item.type === 'content' && item.content?.text) {
contentText = item.content.text;
break;
}
}
// If no text content found, look for other content types
if (!contentText) {
for (const item of content) {
if (item.type === 'content' && item.content) {
contentText = JSON.stringify(item.content, null, 2);
break;
}
}
}
// Look for diff content
for (const item of content) {
if (
item.type === 'diff' &&
(item.oldText !== undefined || item.newText !== undefined)
) {
diffData = {
path: item.path,
oldText: item.oldText ?? null,
newText: item.newText,
};
break;
}
}
}
// Handle open diff
const handleOpenDiffInternal = () => {
if (!diffData) {
return;
}
const path = diffData.path || filePath || '';
handleOpenDiff(vscode, path, diffData.oldText, diffData.newText);
};
return (
<div className="relative pl-[30px] py-2 select-text toolcall-container in-progress-toolcall">
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center gap-2 relative min-w-0 toolcall-header">
<span
className={`text-[14px] leading-none font-bold ${kindColorClass}`}
>
{kindLabel}
</span>
{filePath && (
<FileLink
path={filePath}
line={fileLine ?? undefined}
showFullPath={false}
className="text-[14px]"
/>
)}
{!filePath && fileName && (
<span className="text-[14px] leading-none text-[var(--app-secondary-foreground)]">
{fileName}
</span>
)}
{diffData && (
<button
type="button"
onClick={handleOpenDiffInternal}
className="text-[11px] px-2 py-0.5 border border-[var(--app-input-border)] rounded-small text-[var(--app-primary-foreground)] bg-transparent hover:bg-[var(--app-ghost-button-hover-background)] cursor-pointer"
>
Open Diff
</button>
)}
</div>
{contentText && (
<div className="text-[var(--app-secondary-foreground)]">
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="toolcall-content-text flex-shrink-0 w-full">
{contentText}
</span>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -135,7 +135,8 @@
border: 1px solid var(--app-primary-border-color);
border-radius: var(--corner-radius-small, 4px);
padding: 0.2em 0.4em;
white-space: nowrap;
white-space: pre-wrap; /* 支持自动换行 */
word-break: break-word; /* 在必要时断词 */
}
.markdown-content pre {
@@ -207,7 +208,8 @@
background: none;
border: none;
padding: 0;
white-space: pre;
white-space: pre-wrap; /* 支持自动换行 */
word-break: break-word; /* 在必要时断词 */
}
.markdown-content .file-path-link {

View File

@@ -19,11 +19,12 @@ interface MarkdownRendererProps {
/**
* Regular expressions for parsing content
*/
// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts
const FILE_PATH_REGEX =
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7
const FILE_PATH_WITH_LINES_REGEX =
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
/**
* MarkdownRenderer component - renders markdown content with enhanced features
@@ -166,9 +167,22 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const href = a.getAttribute('href') || '';
const text = (a.textContent || '').trim();
// Helper function to check if a string looks like a code reference
const isCodeReference = (str: string): boolean => {
// Check if it looks like a code reference (e.g., module.property)
// Patterns like "vscode.contribution", "module.submodule.function"
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
return codeRefPattern.test(str);
};
// If linkify turned a bare filename into http://<filename>, convert it back
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
if (httpMatch && BARE_FILE_REGEX.test(text) && httpMatch[1] === text) {
// Skip if it looks like a code reference
if (isCodeReference(text)) {
return;
}
// Treat as a file link instead of external URL
const filePath = text; // no leading slash
a.classList.add('file-path-link');
@@ -182,6 +196,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
if (/^(https?|mailto|ftp|data):/i.test(href)) return;
const candidate = href || text;
// Skip if it looks like a code reference
if (isCodeReference(candidate)) {
return;
}
if (
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
FILE_PATH_NO_G.test(candidate)
@@ -194,6 +214,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
}
};
// Helper function to check if a string looks like a code reference
const isCodeReference = (str: string): boolean => {
// Check if it looks like a code reference (e.g., module.property)
// Patterns like "vscode.contribution", "module.submodule.function"
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
return codeRefPattern.test(str);
};
const walk = (node: Node) => {
// Do not transform inside existing anchors
if (node.nodeType === Node.ELEMENT_NODE) {
@@ -218,6 +246,20 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
while ((m = union.exec(text))) {
const matchText = m[0];
const idx = m.index;
// Skip if it looks like a code reference
if (isCodeReference(matchText)) {
// Just add the text as-is without creating a link
if (idx > lastIndex) {
frag.appendChild(
document.createTextNode(text.slice(lastIndex, idx)),
);
}
frag.appendChild(document.createTextNode(matchText));
lastIndex = idx + matchText.length;
continue;
}
if (idx > lastIndex) {
frag.appendChild(
document.createTextNode(text.slice(lastIndex, idx)),

View File

@@ -0,0 +1,313 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useState, useRef } from 'react';
import type { PermissionOption, ToolCall } from './PermissionRequest.js';
interface PermissionDrawerProps {
isOpen: boolean;
options: PermissionOption[];
toolCall: ToolCall;
onResponse: (optionId: string) => void;
onClose?: () => void;
}
/**
* Permission drawer component - Claude Code style bottom sheet
*/
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
isOpen,
options,
toolCall,
onResponse,
onClose,
}) => {
const [focusedIndex, setFocusedIndex] = useState(0);
const [customMessage, setCustomMessage] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
// 将自定义输入的 ref 类型修正为 HTMLInputElement避免后续强转
const customInputRef = useRef<HTMLInputElement>(null);
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
// Prefer file name from locations, fall back to content[].path if present
const getAffectedFileName = (): string => {
const fromLocations = toolCall.locations?.[0]?.path;
if (fromLocations) {
return fromLocations.split('/').pop() || fromLocations;
}
// Some tool calls (e.g. write/edit with diff content) only include path in content
const fromContent = Array.isArray(toolCall.content)
? (
toolCall.content.find(
(c: unknown) =>
typeof c === 'object' &&
c !== null &&
'path' in (c as Record<string, unknown>),
) as { path?: unknown } | undefined
)?.path
: undefined;
if (typeof fromContent === 'string' && fromContent.length > 0) {
return fromContent.split('/').pop() || fromContent;
}
return 'file';
};
// Get the title for the permission request
const getTitle = () => {
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
const fileName = getAffectedFileName();
return (
<>
Make this edit to{' '}
<span className="font-mono text-[var(--app-primary-foreground)]">
{fileName}
</span>
?
</>
);
}
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
return 'Allow this bash command?';
}
if (toolCall.kind === 'read') {
const fileName = getAffectedFileName();
return (
<>
Allow read from{' '}
<span className="font-mono text-[var(--app-primary-foreground)]">
{fileName}
</span>
?
</>
);
}
return toolCall.title || 'Permission Required';
};
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) {
return;
}
// Number keys 1-9 for quick select
const numMatch = e.key.match(/^[1-9]$/);
if (
numMatch &&
!customInputRef.current?.contains(document.activeElement)
) {
const index = parseInt(e.key, 10) - 1;
if (index < options.length) {
e.preventDefault();
onResponse(options[index].optionId);
}
return;
}
// Arrow keys for navigation
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const totalItems = options.length + 1; // +1 for custom input
if (e.key === 'ArrowDown') {
setFocusedIndex((prev) => (prev + 1) % totalItems);
} else {
setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems);
}
}
// Enter to select
if (
e.key === 'Enter' &&
!customInputRef.current?.contains(document.activeElement)
) {
e.preventDefault();
if (focusedIndex < options.length) {
onResponse(options[focusedIndex].optionId);
}
}
// Escape to cancel permission and close (align with CLI/Claude behavior)
if (e.key === 'Escape') {
e.preventDefault();
const rejectOptionId =
options.find((o) => o.kind.includes('reject'))?.optionId ||
options.find((o) => o.optionId === 'cancel')?.optionId ||
'cancel';
onResponse(rejectOptionId);
if (onClose) onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, options, onResponse, onClose, focusedIndex]);
// Focus container when opened
useEffect(() => {
if (isOpen && containerRef.current) {
containerRef.current.focus();
}
}, [isOpen]);
// Reset focus to the first option when the drawer opens or the options change
useEffect(() => {
if (isOpen) {
setFocusedIndex(0);
}
}, [isOpen, options.length]);
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
{/* Main container */}
<div
ref={containerRef}
className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
style={{
backgroundColor: 'var(--app-input-secondary-background)',
borderColor: 'var(--app-input-border)',
}}
tabIndex={0}
data-focused-index={focusedIndex}
>
{/* Background layer */}
<div
className="p-2 absolute inset-0 rounded-large"
style={{ backgroundColor: 'var(--app-input-background)' }}
/>
{/* Title + Description (from toolCall.title) */}
<div className="relative z-[1] px-1 text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0">
<div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
{getTitle()}
</div>
{(toolCall.kind === 'edit' ||
toolCall.kind === 'write' ||
toolCall.kind === 'read' ||
toolCall.kind === 'execute' ||
toolCall.kind === 'bash') &&
toolCall.title && (
<div
/* 13px常规字重正常空白折行 + 长词断行;最多 3 行溢出省略 */
className="text-[13px] font-normal text-[var(--app-secondary-foreground)] opacity-90 font-mono whitespace-normal break-words q-line-clamp-3 mb-2"
title={toolCall.title}
>
{toolCall.title}
</div>
)}
</div>
{/* Options */}
<div className="relative z-[1] flex flex-col gap-1 px-1 pb-1">
{options.map((option, index) => {
const isFocused = focusedIndex === index;
return (
<button
key={option.optionId}
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-list-hover-background)] ${
isFocused
? 'text-[var(--app-list-active-foreground)] bg-[var(--app-list-active-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
}`}
onClick={() => onResponse(option.optionId)}
onMouseEnter={() => setFocusedIndex(index)}
>
{/* Number badge */}
{/* Plain number badge without hover background */}
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold">
{index + 1}
</span>
{/* Option text */}
<span className="font-semibold">{option.name}</span>
{/* Always badge */}
{/* {isAlways && <span className="text-sm">⚡</span>} */}
</button>
);
})}
{/* Custom message input (extracted component) */}
{(() => {
const isFocused = focusedIndex === options.length;
const rejectOptionId = options.find((o) =>
o.kind.includes('reject'),
)?.optionId;
return (
<CustomMessageInputRow
isFocused={isFocused}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
onFocusRow={() => setFocusedIndex(options.length)}
onSubmitReject={() => {
if (rejectOptionId) onResponse(rejectOptionId);
}}
inputRef={customInputRef}
/>
);
})()}
</div>
</div>
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
</div>
);
};
/**
* CustomMessageInputRow: 复用的自定义输入行组件(无 hooks
*/
interface CustomMessageInputRowProps {
isFocused: boolean;
customMessage: string;
setCustomMessage: (val: string) => void;
onFocusRow: () => void; // 鼠标移入或输入框 focus 时设置焦点
onSubmitReject: () => void; // Enter 提交时触发(选择 reject 选项)
inputRef: React.RefObject<HTMLInputElement>;
}
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
isFocused,
customMessage,
setCustomMessage,
onFocusRow,
onSubmitReject,
inputRef,
}) => (
<div
// 无过渡hover 样式立即生效;输入行不加 hover 背景,也不加粗文字
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
}`}
onMouseEnter={onFocusRow}
onClick={() => inputRef.current?.focus()}
>
{/* 输入行不显示序号徽标 */}
{/* Input field */}
<input
ref={inputRef}
type="text"
placeholder="Tell Qwen what to do instead"
spellCheck={false}
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
style={{ color: 'var(--app-input-foreground)' }}
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
onFocus={onFocusRow}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
e.preventDefault();
onSubmitReject();
}
}}
/>
</div>
);

View File

@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface PermissionOption {
name: string;
kind: string;
optionId: string;
}
export interface ToolCall {
title?: string;
kind?: string;
toolCallId?: string;
rawInput?: {
command?: string;
description?: string;
[key: string]: unknown;
};
content?: Array<{
type: string;
[key: string]: unknown;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
status?: string;
}
export interface PermissionRequestProps {
options: PermissionOption[];
toolCall: ToolCall;
onResponse: (optionId: string) => void;
}

View File

@@ -1,227 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface PermissionOption {
name: string;
kind: string;
optionId: string;
}
export interface ToolCall {
title?: string;
kind?: string;
toolCallId?: string;
rawInput?: {
command?: string;
description?: string;
[key: string]: unknown;
};
content?: Array<{
type: string;
[key: string]: unknown;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
status?: string;
}
export interface PermissionRequestProps {
options: PermissionOption[];
toolCall: ToolCall;
onResponse: (optionId: string) => void;
}
// export const PermissionRequest: React.FC<PermissionRequestProps> = ({
// options,
// toolCall,
// onResponse,
// }) => {
// const [selected, setSelected] = useState<string | null>(null);
// const [isResponding, setIsResponding] = useState(false);
// const [hasResponded, setHasResponded] = useState(false);
// const getToolInfo = () => {
// if (!toolCall) {
// return {
// title: 'Permission Request',
// description: 'Agent is requesting permission',
// icon: '🔐',
// };
// }
// const displayTitle =
// toolCall.title || toolCall.rawInput?.description || 'Permission Request';
// const kindIcons: Record<string, string> = {
// edit: '✏️',
// read: '📖',
// fetch: '🌐',
// execute: '⚡',
// delete: '🗑️',
// move: '📦',
// search: '🔍',
// think: '💭',
// other: '🔧',
// };
// return {
// title: displayTitle,
// icon: kindIcons[toolCall.kind || 'other'] || '🔧',
// };
// };
// const { title, icon } = getToolInfo();
// const handleConfirm = async () => {
// if (hasResponded || !selected) {
// return;
// }
// setIsResponding(true);
// try {
// await onResponse(selected);
// setHasResponded(true);
// } catch (error) {
// console.error('Error confirming permission:', error);
// } finally {
// setIsResponding(false);
// }
// };
// if (!toolCall) {
// return null;
// }
// return (
// <div className="permission-request-card">
// <div className="permission-card-body">
// {/* Header with icon and title */}
// <div className="permission-header">
// <div className="permission-icon-wrapper">
// <span className="permission-icon">{icon}</span>
// </div>
// <div className="permission-info">
// <div className="permission-title">{title}</div>
// <div className="permission-subtitle">Waiting for your approval</div>
// </div>
// </div>
// {/* Show command if available */}
// {(toolCall.rawInput?.command || toolCall.title) && (
// <div className="permission-command-section">
// <div className="permission-command-header">
// <div className="permission-command-status">
// <span className="permission-command-dot">●</span>
// <span className="permission-command-label">COMMAND</span>
// </div>
// </div>
// <div className="permission-command-content">
// <div className="permission-command-input-section">
// <span className="permission-command-io-label">IN</span>
// <code className="permission-command-code">
// {toolCall.rawInput?.command || toolCall.title}
// </code>
// </div>
// {toolCall.rawInput?.description && (
// <div className="permission-command-description">
// {toolCall.rawInput.description}
// </div>
// )}
// </div>
// </div>
// )}
// {/* Show file locations if available */}
// {toolCall.locations && toolCall.locations.length > 0 && (
// <div className="permission-locations-section">
// <div className="permission-locations-label">Affected Files</div>
// {toolCall.locations.map((location, index) => (
// <div key={index} className="permission-location-item">
// <span className="permission-location-icon">📄</span>
// <span className="permission-location-path">
// {location.path}
// </span>
// {location.line !== null && location.line !== undefined && (
// <span className="permission-location-line">
// ::{location.line}
// </span>
// )}
// </div>
// ))}
// </div>
// )}
// {/* Options */}
// {!hasResponded && (
// <div className="permission-options-section">
// <div className="permission-options-label">Choose an action:</div>
// <div className="permission-options-list">
// {options && options.length > 0 ? (
// options.map((option, index) => {
// const isSelected = selected === option.optionId;
// const isAllow = option.kind.includes('allow');
// const isAlways = option.kind.includes('always');
// return (
// <label
// key={option.optionId}
// className={`permission-option ${isSelected ? 'selected' : ''} ${
// isAllow ? 'allow' : 'reject'
// } ${isAlways ? 'always' : ''}`}
// >
// <input
// type="radio"
// name="permission"
// value={option.optionId}
// checked={isSelected}
// onChange={() => setSelected(option.optionId)}
// className="permission-radio"
// />
// <span className="permission-option-content">
// <span className="permission-option-number">
// {index + 1}
// </span>
// {isAlways && (
// <span className="permission-always-badge">⚡</span>
// )}
// {option.name}
// </span>
// </label>
// );
// })
// ) : (
// <div className="permission-no-options">
// No options available
// </div>
// )}
// </div>
// <div className="permission-actions">
// <button
// className="permission-confirm-button"
// disabled={!selected || isResponding}
// onClick={handleConfirm}
// >
// {isResponding ? 'Processing...' : 'Confirm'}
// </button>
// </div>
// </div>
// )}
// {/* Success message */}
// {hasResponded && (
// <div className="permission-success">
// <span className="permission-success-icon">✓</span>
// <span className="permission-success-text">
// Response sent successfully
// </span>
// </div>
// )}
// </div>
// </div>
// );
// };

View File

@@ -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 }) => <ToolCallRouter toolCall={toolCall} />;
isFirst?: boolean;
isLast?: boolean;
}> = ({ toolCall, isFirst, isLast }) => (
<ToolCallRouter toolCall={toolCall} isFirst={isFirst} isLast={isLast} />
);

View File

@@ -68,12 +68,12 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
paddingLeft: '30px',
userSelect: 'text',
position: 'relative',
paddingTop: '8px',
paddingBottom: '8px',
// paddingTop: '8px',
// paddingBottom: '8px',
}}
>
<span style={{ width: '100%' }}>
<p
<div
style={{
margin: 0,
width: '100%',
@@ -83,7 +83,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
}}
>
<MessageContent content={content} onFileClick={onFileClick} />
</p>
</div>
</span>
</div>
);

View File

@@ -67,7 +67,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
<div
role="button"
tabIndex={0}
className="mr inline-flex items-center py-0 pl-1 pr-2 ml-1 gap-1 rounded-sm cursor-pointer relative opacity-50 hover:opacity-100"
className="mr inline-flex items-center py-0 pl-1 pr-2 ml-1 gap-1 rounded-sm cursor-pointer relative opacity-50"
onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -10,4 +10,3 @@ export { ThinkingMessage } from './ThinkingMessage.js';
export { StreamingMessage } from './StreamingMessage.js';
export { WaitingMessage } from './Waiting/WaitingMessage.js';
export { InterruptedMessage } from './Waiting/InterruptedMessage.js';
export { PlanDisplay } from '../PlanDisplay.js';

View File

@@ -71,9 +71,9 @@ export const CheckboxDisplay: React.FC<CheckboxDisplayProps> = ({
aria-hidden
className={[
'absolute inline-block',
'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'left-1/2 top-[10px] -translate-x-1/2 -translate-y-1/2',
// Use a literal star; no icon font needed
'text-[11px] leading-none text-[#e1c08d] select-none',
'text-[16px] leading-none text-[#e1c08d] select-none',
].join(' ')}
>
*

View File

@@ -64,7 +64,7 @@ export class AuthMessageHandler extends BaseMessageHandler {
vscode.window.showInformationMessage(
'Please wait while we connect to Qwen Code...',
);
await vscode.commands.executeCommand('qwenCode.login');
await vscode.commands.executeCommand('qwen-code.login');
}
} catch (error) {
console.error('[AuthMessageHandler] Login failed:', error);