mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
style(vscode-ide-companion): adjust chat session initialization logic and optimize tool invocation component style
This commit is contained in:
@@ -73,12 +73,7 @@ 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
|
||||
}
|
||||
// Session restoration is now disabled by default, so no need to suppress it
|
||||
await provider.show();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -224,6 +224,7 @@ export const App: React.FC = () => {
|
||||
handlePermissionRequest: setPermissionRequest,
|
||||
inputFieldRef,
|
||||
setInputText,
|
||||
setEditMode,
|
||||
});
|
||||
|
||||
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
||||
@@ -473,15 +474,22 @@ export const App: React.FC = () => {
|
||||
// Handle toggle edit mode
|
||||
const handleToggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => {
|
||||
if (prev === 'ask') {
|
||||
return 'auto';
|
||||
const next: EditMode =
|
||||
prev === 'ask' ? 'auto' : prev === 'auto' ? 'plan' : 'ask';
|
||||
// Notify extension to set approval mode via ACP
|
||||
try {
|
||||
const toAcp =
|
||||
next === 'plan' ? 'plan' : next === 'auto' ? 'auto-edit' : 'default';
|
||||
vscode.postMessage({
|
||||
type: 'setApprovalMode',
|
||||
data: { modeId: toAcp },
|
||||
});
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
if (prev === 'auto') {
|
||||
return 'plan';
|
||||
}
|
||||
return 'ask';
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [vscode]);
|
||||
|
||||
// Handle toggle thinking
|
||||
const handleToggleThinking = () => {
|
||||
|
||||
@@ -26,8 +26,6 @@ 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,
|
||||
@@ -242,13 +240,6 @@ 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();
|
||||
|
||||
@@ -383,6 +374,22 @@ export class WebViewProvider {
|
||||
type: 'activeEditorChanged',
|
||||
data: { fileName, filePath, selection: selectionInfo },
|
||||
});
|
||||
|
||||
// Surface available modes and current mode (from ACP initialize)
|
||||
this.agentManager.onModeInfo((info) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'modeInfo',
|
||||
data: info || {},
|
||||
});
|
||||
});
|
||||
|
||||
// Surface mode changes (from ACP or immediate set_mode response)
|
||||
this.agentManager.onModeChanged((modeId) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'modeChanged',
|
||||
data: { modeId },
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
this.disposables.push(selectionChangeDisposable);
|
||||
@@ -681,199 +688,40 @@ export class WebViewProvider {
|
||||
|
||||
/**
|
||||
* Load messages from current Qwen session
|
||||
* Attempts to restore an existing session before creating a new one
|
||||
* Skips session restoration and creates a new session directly
|
||||
*/
|
||||
private async loadCurrentSessionMessages(): Promise<void> {
|
||||
try {
|
||||
console.log(
|
||||
'[WebViewProvider] Initializing with session restoration attempt',
|
||||
'[WebViewProvider] Initializing with new session (skipping restoration)',
|
||||
);
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
|
||||
// First, try to restore an existing session if we have cached auth
|
||||
if (this.authStateManager) {
|
||||
const hasValidAuth = await this.authStateManager.hasValidAuth(
|
||||
// Skip session restoration entirely and create a new session directly
|
||||
try {
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
authMethod,
|
||||
this.authStateManager,
|
||||
);
|
||||
if (hasValidAuth) {
|
||||
const allowAutoRestore = this.autoRestoreOnFirstConnect;
|
||||
// Reset for subsequent connects (only once per panel lifecycle unless set again)
|
||||
this.autoRestoreOnFirstConnect = true;
|
||||
console.log('[WebViewProvider] ACP session created successfully');
|
||||
|
||||
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 {
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
);
|
||||
console.log('[WebViewProvider] ACP session created successfully');
|
||||
|
||||
// Ensure auth state is saved after successful session creation
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log(
|
||||
'[WebViewProvider] Auth state saved after session creation',
|
||||
);
|
||||
}
|
||||
} 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 {
|
||||
// Ensure auth state is saved after successful session creation
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log(
|
||||
'[WebViewProvider] No valid cached auth found, creating new session',
|
||||
'[WebViewProvider] Auth state saved after session creation',
|
||||
);
|
||||
// No valid auth, create a new session (will trigger auth if needed)
|
||||
try {
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
);
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No auth state manager, create a new session
|
||||
console.log(
|
||||
'[WebViewProvider] No auth state manager, creating new session',
|
||||
} 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.`,
|
||||
);
|
||||
try {
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
);
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.initializeEmptyConversation();
|
||||
|
||||
@@ -185,7 +185,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
/>
|
||||
|
||||
{/* 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="relative z-[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>
|
||||
@@ -198,6 +198,11 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
<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"
|
||||
style={{
|
||||
fontSize: '.9em',
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
title={toolCall.title}
|
||||
>
|
||||
{toolCall.title}
|
||||
@@ -206,14 +211,14 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative z-[1] flex flex-col gap-1 px-1 pb-1">
|
||||
<div className="relative z-[1] flex flex-col gap-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)] ${
|
||||
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-button-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'
|
||||
@@ -222,15 +227,11 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
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">
|
||||
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold opacity-60">
|
||||
{index + 1}
|
||||
</span>
|
||||
{/* Option text */}
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
|
||||
{/* Always badge */}
|
||||
{/* {isAlways && <span className="text-sm">⚡</span>} */}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -283,15 +284,12 @@ const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
|
||||
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"
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { CheckboxDisplay } from './ui/CheckboxDisplay.js';
|
||||
|
||||
export interface PlanEntry {
|
||||
content: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
interface PlanDisplayProps {
|
||||
entries: PlanEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanDisplay component - displays AI's task plan/todo list
|
||||
*/
|
||||
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
||||
// Calculate overall status for left dot color
|
||||
const allCompleted =
|
||||
entries.length > 0 && entries.every((e) => e.status === 'completed');
|
||||
const anyInProgress = entries.some((e) => e.status === 'in_progress');
|
||||
const statusDotClass = allCompleted
|
||||
? 'before:text-[#74c991]'
|
||||
: anyInProgress
|
||||
? 'before:text-[#e1c08d]'
|
||||
: 'before:text-[var(--app-secondary-foreground)]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
// Container: Similar to example .A/.e
|
||||
'relative flex flex-col items-start py-2 pl-[30px] select-text text-[var(--app-primary-foreground)]',
|
||||
// Left status dot, similar to example .e:before
|
||||
'before:content-["\\25cf"] before:absolute before:left-[10px] before:top-[12px] before:text-[10px] before:z-[1]',
|
||||
statusDotClass,
|
||||
// Original plan-display styles: bg-transparent border-0 py-2 px-4 my-2
|
||||
'bg-transparent border-0 my-2',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Title area, similar to example summary/_e/or */}
|
||||
<div className="w-full flex items-center gap-1.5 mb-2">
|
||||
<div className="relative">
|
||||
<div className="list-none line-clamp-2 max-w-full overflow-hidden _e">
|
||||
<span>
|
||||
<div>
|
||||
<span className="or font-bold mr-1">Update Todos</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List area, similar to example .qr/.Fr/.Hr */}
|
||||
<div className="qr grid-cols-1 flex flex-col py-2">
|
||||
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
|
||||
{entries.map((entry, index) => {
|
||||
const isDone = entry.status === 'completed';
|
||||
const isIndeterminate = entry.status === 'in_progress';
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={[
|
||||
'Hr flex items-start gap-2 p-0 rounded text-[var(--app-primary-foreground)]',
|
||||
isDone ? 'fo opacity-70' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Display checkbox (reusable component) */}
|
||||
<label className="flex items-start gap-2">
|
||||
<CheckboxDisplay
|
||||
checked={isDone}
|
||||
indeterminate={isIndeterminate}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'vo plan-entry-text flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)]',
|
||||
isDone
|
||||
? 'line-through text-[var(--app-secondary-foreground)] opacity-70'
|
||||
: 'opacity-85',
|
||||
].join(' ')}
|
||||
>
|
||||
{entry.content}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
className="mr inline-flex items-center py-0 pr-2 gap-1 rounded-sm cursor-pointer relative opacity-50"
|
||||
onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -15,17 +15,7 @@ export const InterruptedMessage: React.FC<InterruptedMessageProps> = ({
|
||||
text = 'Interrupted',
|
||||
}) => (
|
||||
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85">
|
||||
<div
|
||||
className="qwen-message message-item interrupted-item"
|
||||
style={{
|
||||
width: '100%',
|
||||
alignItems: 'flex-start',
|
||||
paddingLeft: '10px', // keep alignment with other assistant messages, but no status icon
|
||||
position: 'relative',
|
||||
paddingTop: '8px',
|
||||
paddingBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div className="interrupted-item w-full relative">
|
||||
<span className="opacity-70 italic">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,16 +8,41 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
import { groupContent, safeTitle } from '../shared/utils.js';
|
||||
import { CheckboxDisplay } from '../../ui/CheckboxDisplay.js';
|
||||
import type { PlanEntry } from '../../../../agents/qwenTypes.js';
|
||||
|
||||
type EntryStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
interface PlanEntry {
|
||||
content: string;
|
||||
status: EntryStatus;
|
||||
}
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
status = 'success',
|
||||
children,
|
||||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
}) => (
|
||||
<div
|
||||
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
>
|
||||
<div className="UpdatedPlanToolCall toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||
{labelSuffix}
|
||||
</span>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="text-[var(--app-secondary-foreground)] py-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const mapToolStatusToBullet = (
|
||||
status: import('../shared/types.js').ToolCallStatus,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileLink } from '../../../ui/FileLink.js';
|
||||
import type { ToolCallContainerProps } from '../../shared/LayoutComponents.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../../../../utils/diffUtils.js';
|
||||
import { DiffDisplay } from '../../shared/DiffDisplay.js';
|
||||
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
@@ -109,6 +110,64 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [toolCallId]);
|
||||
|
||||
// Failed case: show explicit failed message and render inline diffs
|
||||
if (toolCall.status === 'failed') {
|
||||
const firstDiff = diffs[0];
|
||||
const path = firstDiff?.path || locations?.[0]?.path || '';
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<div
|
||||
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
|
||||
>
|
||||
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center justify-between min-w-0">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-[13px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
Edit
|
||||
</span>
|
||||
{path && (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Failed state text (replace summary) */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-center">
|
||||
<span className="flex-shrink-0 w-full">edit failed</span>
|
||||
</div>
|
||||
{/* Inline diff preview(s) */}
|
||||
{diffs.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
{diffs.map(
|
||||
(
|
||||
item: import('../../shared/types.js').ToolCallContent,
|
||||
idx: number,
|
||||
) => (
|
||||
<DiffDisplay
|
||||
key={`diff-${idx}`}
|
||||
path={item.path}
|
||||
oldText={item.oldText}
|
||||
newText={item.newText}
|
||||
onOpenDiff={() =>
|
||||
handleOpenDiffInternal(
|
||||
item.path || path,
|
||||
item.oldText,
|
||||
item.newText,
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error case: show error
|
||||
if (errors.length > 0) {
|
||||
const path = diffs[0]?.path || locations?.[0]?.path || '';
|
||||
@@ -122,7 +181,7 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
className="text-xs font-mono hover:underline"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -141,21 +200,19 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
return (
|
||||
<div
|
||||
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
|
||||
title="Open diff in VS Code"
|
||||
>
|
||||
{/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */}
|
||||
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center justify-between min-w-0">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
{/* Align the inline Edit label styling with shared toolcall label: larger + bold */}
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
<span className="text-[13px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
Edit
|
||||
</span>
|
||||
{path && (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
className="font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../../shared/types.js';
|
||||
import {
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
} from '../../shared/LayoutComponents.js';
|
||||
import { FileLink } from '../../../ui/FileLink.js';
|
||||
import {
|
||||
safeTitle,
|
||||
groupContent,
|
||||
@@ -53,7 +49,128 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
* Optimized for displaying search operations and results
|
||||
* 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 queryText = safeTitle(title);
|
||||
|
||||
@@ -63,14 +180,14 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
// Error case: show search query + error in card layout
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="🔍">
|
||||
<ToolCallRow label="Search">
|
||||
<SearchCard status="error" isFirst={isFirst} isLast={isLast}>
|
||||
<SearchRow label="Search">
|
||||
<div className="font-mono">{queryText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Error">
|
||||
<div className="text-[#c74e39] font-medium">{errors.join('\n')}</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
</SearchRow>
|
||||
<SearchRow label="Error">
|
||||
<div className="text-qwen-error font-medium">{errors.join('\n')}</div>
|
||||
</SearchRow>
|
||||
</SearchCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,28 +197,27 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
// If multiple results, use card layout; otherwise use compact format
|
||||
if (locations.length > 1) {
|
||||
return (
|
||||
<ToolCallCard icon="🔍">
|
||||
<ToolCallRow label="Search">
|
||||
<SearchCard status={containerStatus} isFirst={isFirst} isLast={isLast}>
|
||||
<SearchRow label="Search">
|
||||
<div className="font-mono">{queryText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label={`Found (${locations.length})`}>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
</SearchRow>
|
||||
<SearchRow label={`Found (${locations.length})`}>
|
||||
<LocationsListLocal locations={locations} />
|
||||
</SearchRow>
|
||||
</SearchCard>
|
||||
);
|
||||
}
|
||||
// Single result - compact format
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Search"
|
||||
<InlineContainer
|
||||
status={containerStatus}
|
||||
className="search-toolcall"
|
||||
labelSuffix={`(${queryText})`}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
{/* <span className="font-mono">{queryText}</span> */}
|
||||
<span className="mx-2 opacity-50">→</span>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallContainer>
|
||||
<LocationsListLocal locations={locations} />
|
||||
</InlineContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,11 +225,11 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
if (textOutputs.length > 0) {
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Search"
|
||||
<InlineContainer
|
||||
status={containerStatus}
|
||||
className="search-toolcall"
|
||||
labelSuffix={queryText ? `(${queryText})` : undefined}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{textOutputs.map((text, index) => (
|
||||
@@ -126,7 +242,7 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
</InlineContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,13 +250,13 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
if (queryText) {
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Search"
|
||||
<InlineContainer
|
||||
status={containerStatus}
|
||||
className="search-toolcall"
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<span className="font-mono">{queryText}</span>
|
||||
</ToolCallContainer>
|
||||
</InlineContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,8 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
<div
|
||||
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 */}
|
||||
{/* TODO: gap-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">
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface ToolCallData {
|
||||
*/
|
||||
export interface BaseToolCallProps {
|
||||
toolCall: ToolCallData;
|
||||
// Optional timeline flags for rendering connector line cropping
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
*/
|
||||
export class SettingsMessageHandler extends BaseMessageHandler {
|
||||
canHandle(messageType: string): boolean {
|
||||
return ['openSettings', 'recheckCli'].includes(messageType);
|
||||
return ['openSettings', 'recheckCli', 'setApprovalMode'].includes(
|
||||
messageType,
|
||||
);
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
@@ -26,6 +28,14 @@ export class SettingsMessageHandler extends BaseMessageHandler {
|
||||
await this.handleRecheckCli();
|
||||
break;
|
||||
|
||||
case 'setApprovalMode':
|
||||
await this.handleSetApprovalMode(
|
||||
message.data as {
|
||||
modeId?: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[SettingsMessageHandler] Unknown message type:',
|
||||
@@ -68,4 +78,29 @@ export class SettingsMessageHandler extends BaseMessageHandler {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode via agent (ACP session/set_mode)
|
||||
*/
|
||||
private async handleSetApprovalMode(data?: {
|
||||
modeId?: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const modeId = (data?.modeId || 'default') as
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'yolo';
|
||||
await this.agentManager.setApprovalModeFromUi(
|
||||
modeId === 'plan' ? 'plan' : modeId === 'auto-edit' ? 'auto' : 'ask',
|
||||
);
|
||||
// No explicit response needed; WebView listens for modeChanged
|
||||
} catch (error) {
|
||||
console.error('[SettingsMessageHandler] Failed to set mode:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to set mode: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,26 +70,32 @@ export const useMessageHandling = () => {
|
||||
/**
|
||||
* Add stream chunk
|
||||
*/
|
||||
const appendStreamChunk = useCallback((chunk: string) => {
|
||||
setMessages((prev) => {
|
||||
let idx = streamingMessageIndexRef.current;
|
||||
const next = prev.slice();
|
||||
const appendStreamChunk = useCallback(
|
||||
(chunk: string) => {
|
||||
// Ignore late chunks after user cancelled streaming (until next streamStart)
|
||||
if (!isStreaming) return;
|
||||
|
||||
// If there is no active placeholder (e.g., after a tool call), start a new one
|
||||
if (idx === null) {
|
||||
idx = next.length;
|
||||
streamingMessageIndexRef.current = idx;
|
||||
next.push({ role: 'assistant', content: '', timestamp: Date.now() });
|
||||
}
|
||||
setMessages((prev) => {
|
||||
let idx = streamingMessageIndexRef.current;
|
||||
const next = prev.slice();
|
||||
|
||||
if (idx < 0 || idx >= next.length) {
|
||||
return prev;
|
||||
}
|
||||
const target = next[idx];
|
||||
next[idx] = { ...target, content: (target.content || '') + chunk };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// If there is no active placeholder (e.g., after a tool call), start a new one
|
||||
if (idx === null) {
|
||||
idx = next.length;
|
||||
streamingMessageIndexRef.current = idx;
|
||||
next.push({ role: 'assistant', content: '', timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (idx < 0 || idx >= next.length) {
|
||||
return prev;
|
||||
}
|
||||
const target = next[idx];
|
||||
next[idx] = { ...target, content: (target.content || '') + chunk };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isStreaming],
|
||||
);
|
||||
|
||||
/**
|
||||
* Break current assistant stream segment (e.g., when a tool call starts/updates)
|
||||
@@ -150,6 +156,8 @@ export const useMessageHandling = () => {
|
||||
endStreaming,
|
||||
// Thought handling
|
||||
appendThinkingChunk: (chunk: string) => {
|
||||
// Ignore late thoughts after user cancelled streaming
|
||||
if (!isStreaming) return;
|
||||
setMessages((prev) => {
|
||||
let idx = thinkingMessageIndexRef.current;
|
||||
const next = prev.slice();
|
||||
|
||||
Reference in New Issue
Block a user