style(vscode-ide-companion): adjust chat session initialization logic and optimize tool invocation component style

This commit is contained in:
yiliang114
2025-12-06 22:44:31 +08:00
15 changed files with 373 additions and 387 deletions

View File

@@ -73,12 +73,7 @@ export function registerNewCommands(
disposables.push( disposables.push(
vscode.commands.registerCommand(openNewChatTabCommand, async () => { vscode.commands.registerCommand(openNewChatTabCommand, async () => {
const provider = createWebViewProvider(); const provider = createWebViewProvider();
// Suppress auto-restore for this newly created tab so it starts clean // Session restoration is now disabled by default, so no need to suppress it
try {
provider.suppressAutoRestoreOnce?.();
} catch {
// ignore if older provider does not implement the method
}
await provider.show(); await provider.show();
}), }),
); );

View File

@@ -224,6 +224,7 @@ export const App: React.FC = () => {
handlePermissionRequest: setPermissionRequest, handlePermissionRequest: setPermissionRequest,
inputFieldRef, inputFieldRef,
setInputText, setInputText,
setEditMode,
}); });
// Auto-scroll handling: keep the view pinned to bottom when new content arrives, // 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 // Handle toggle edit mode
const handleToggleEditMode = useCallback(() => { const handleToggleEditMode = useCallback(() => {
setEditMode((prev) => { setEditMode((prev) => {
if (prev === 'ask') { const next: EditMode =
return 'auto'; 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 next;
return 'plan';
}
return 'ask';
}); });
}, []); }, [vscode]);
// Handle toggle thinking // Handle toggle thinking
const handleToggleThinking = () => { const handleToggleThinking = () => {

View File

@@ -26,8 +26,6 @@ export class WebViewProvider {
private authStateManager: AuthStateManager; private authStateManager: AuthStateManager;
private disposables: vscode.Disposable[] = []; private disposables: vscode.Disposable[] = [];
private agentInitialized = false; // Track if agent has been initialized private agentInitialized = false; // Track if agent has been initialized
// Control whether to auto-restore last session on the very first connect of this panel
private autoRestoreOnFirstConnect = true;
constructor( constructor(
context: vscode.ExtensionContext, context: vscode.ExtensionContext,
@@ -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> { async show(): Promise<void> {
const panel = this.panelManager.getPanel(); const panel = this.panelManager.getPanel();
@@ -383,6 +374,22 @@ export class WebViewProvider {
type: 'activeEditorChanged', type: 'activeEditorChanged',
data: { fileName, filePath, selection: selectionInfo }, 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); this.disposables.push(selectionChangeDisposable);
@@ -681,199 +688,40 @@ export class WebViewProvider {
/** /**
* Load messages from current Qwen session * 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> { private async loadCurrentSessionMessages(): Promise<void> {
try { try {
console.log( console.log(
'[WebViewProvider] Initializing with session restoration attempt', '[WebViewProvider] Initializing with new session (skipping restoration)',
); );
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
// First, try to restore an existing session if we have cached auth // Skip session restoration entirely and create a new session directly
if (this.authStateManager) { try {
const hasValidAuth = await this.authStateManager.hasValidAuth( await this.agentManager.createNewSession(
workingDir, workingDir,
authMethod, this.authStateManager,
); );
if (hasValidAuth) { console.log('[WebViewProvider] ACP session created successfully');
const allowAutoRestore = this.autoRestoreOnFirstConnect;
// Reset for subsequent connects (only once per panel lifecycle unless set again)
this.autoRestoreOnFirstConnect = true;
if (allowAutoRestore) { // Ensure auth state is saved after successful session creation
console.log( if (this.authStateManager) {
'[WebViewProvider] Valid auth found, attempting auto-restore of last session...', await this.authStateManager.saveAuthState(workingDir, authMethod);
);
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 {
console.log( 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 { } catch (sessionError) {
// No auth state manager, create a new session console.error(
console.log( '[WebViewProvider] Failed to create ACP session:',
'[WebViewProvider] No auth state manager, creating new 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(); await this.initializeEmptyConversation();

View File

@@ -185,7 +185,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
/> />
{/* Title + Description (from toolCall.title) */} {/* 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"> <div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
{getTitle()} {getTitle()}
</div> </div>
@@ -198,6 +198,11 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
<div <div
/* 13px常规字重正常空白折行 + 长词断行;最多 3 行溢出省略 */ /* 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" 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} title={toolCall.title}
> >
{toolCall.title} {toolCall.title}
@@ -206,14 +211,14 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
</div> </div>
{/* Options */} {/* 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) => { {options.map((option, index) => {
const isFocused = focusedIndex === index; const isFocused = focusedIndex === index;
return ( return (
<button <button
key={option.optionId} 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 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' ? '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' : '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)} onMouseEnter={() => setFocusedIndex(index)}
> >
{/* Number badge */} {/* Number badge */}
{/* Plain number badge without hover background */} <span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold opacity-60">
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold">
{index + 1} {index + 1}
</span> </span>
{/* Option text */} {/* Option text */}
<span className="font-semibold">{option.name}</span> <span className="font-semibold">{option.name}</span>
{/* Always badge */}
{/* {isAlways && <span className="text-sm">⚡</span>} */}
</button> </button>
); );
})} })}
@@ -283,15 +284,12 @@ const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
inputRef, inputRef,
}) => ( }) => (
<div <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)] ${ 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)]' : '' isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
}`} }`}
onMouseEnter={onFocusRow} onMouseEnter={onFocusRow}
onClick={() => inputRef.current?.focus()} onClick={() => inputRef.current?.focus()}
> >
{/* 输入行不显示序号徽标 */}
{/* Input field */}
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"

View File

@@ -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>
);
};

View File

@@ -67,7 +67,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
<div <div
role="button" role="button"
tabIndex={0} 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)} onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -15,17 +15,7 @@ export const InterruptedMessage: React.FC<InterruptedMessageProps> = ({
text = 'Interrupted', text = 'Interrupted',
}) => ( }) => (
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85"> <div className="flex gap-0 items-start text-left py-2 flex-col opacity-85">
<div <div className="interrupted-item w-full relative">
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',
}}
>
<span className="opacity-70 italic">{text}</span> <span className="opacity-70 italic">{text}</span>
</div> </div>
</div> </div>

View File

@@ -8,16 +8,41 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js'; import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
import { groupContent, safeTitle } from '../shared/utils.js'; import { groupContent, safeTitle } from '../shared/utils.js';
import { CheckboxDisplay } from '../../ui/CheckboxDisplay.js'; import { CheckboxDisplay } from '../../ui/CheckboxDisplay.js';
import type { PlanEntry } from '../../../../agents/qwenTypes.js';
type EntryStatus = 'pending' | 'in_progress' | 'completed'; type EntryStatus = 'pending' | 'in_progress' | 'completed';
interface PlanEntry { export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
content: string; label,
status: EntryStatus; 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 = ( const mapToolStatusToBullet = (
status: import('../shared/types.js').ToolCallStatus, status: import('../shared/types.js').ToolCallStatus,

View File

@@ -16,6 +16,7 @@ import { FileLink } from '../../../ui/FileLink.js';
import type { ToolCallContainerProps } from '../../shared/LayoutComponents.js'; import type { ToolCallContainerProps } from '../../shared/LayoutComponents.js';
import { useVSCode } from '../../../../hooks/useVSCode.js'; import { useVSCode } from '../../../../hooks/useVSCode.js';
import { handleOpenDiff } from '../../../../utils/diffUtils.js'; import { handleOpenDiff } from '../../../../utils/diffUtils.js';
import { DiffDisplay } from '../../shared/DiffDisplay.js';
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
label, label,
@@ -109,6 +110,64 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolCallId]); }, [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 // Error case: show error
if (errors.length > 0) { if (errors.length > 0) {
const path = diffs[0]?.path || locations?.[0]?.path || ''; const path = diffs[0]?.path || locations?.[0]?.path || '';
@@ -122,7 +181,7 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<FileLink <FileLink
path={path} path={path}
showFullPath={false} showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline" className="text-xs font-mono hover:underline"
/> />
) : undefined ) : undefined
} }
@@ -141,21 +200,19 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
return ( return (
<div <div
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`} 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="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-center justify-between min-w-0">
<div className="flex items-baseline gap-1.5 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 */} {/* 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 Edit
</span> </span>
{path && ( {path && (
<FileLink <FileLink
path={path} path={path}
showFullPath={false} 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> </div>

View File

@@ -8,11 +8,7 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from '../../shared/types.js'; import type { BaseToolCallProps } from '../../shared/types.js';
import { import { FileLink } from '../../../ui/FileLink.js';
ToolCallCard,
ToolCallRow,
LocationsList,
} from '../../shared/LayoutComponents.js';
import { import {
safeTitle, safeTitle,
groupContent, groupContent,
@@ -53,7 +49,128 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
* Optimized for displaying search operations and results * Optimized for displaying search operations and results
* Shows query + result count or file list * Shows query + result count or file list
*/ */
export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => { // Local, scoped inline container for compact search rows (single result/text-only)
const InlineContainer: React.FC<{
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
labelSuffix?: string;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
}> = ({ status, labelSuffix, children, isFirst, isLast }) => {
const beforeStatusClass =
status === 'success'
? 'before:text-qwen-success'
: status === 'error'
? 'before:text-qwen-error'
: status === 'warning'
? 'before:text-qwen-warning'
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
const lineCropBottom = isLast
? 'bottom-auto h-[calc(100%-24px)]'
: 'bottom-0';
return (
<div
className={
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
beforeStatusClass
}
>
{/* timeline vertical line */}
<div
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
aria-hidden
/>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
Search
</span>
{labelSuffix ? (
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
{labelSuffix}
</span>
) : null}
</div>
{children ? (
<div className="mt-1 text-[var(--app-secondary-foreground)]">
{children}
</div>
) : null}
</div>
</div>
);
};
// Local card layout for multi-result or error display
const SearchCard: React.FC<{
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
children: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
}> = ({ status, children, isFirst, isLast }) => {
const beforeStatusClass =
status === 'success'
? 'before:text-qwen-success'
: status === 'error'
? 'before:text-qwen-error'
: status === 'warning'
? 'before:text-qwen-warning'
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
const lineCropBottom = isLast
? 'bottom-auto h-[calc(100%-24px)]'
: 'bottom-0';
return (
<div
className={
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
beforeStatusClass
}
>
{/* timeline vertical line */}
<div
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
aria-hidden
/>
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium">
<div className="flex flex-col gap-3 min-w-0">{children}</div>
</div>
</div>
);
};
const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({
label,
children,
}) => (
<div className="grid grid-cols-[80px_1fr] gap-medium min-w-0">
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
{label}
</div>
<div className="text-[var(--app-primary-foreground)] min-w-0 break-words">
{children}
</div>
</div>
);
const LocationsListLocal: React.FC<{
locations: Array<{ path: string; line?: number | null }>;
}> = ({ locations }) => (
<div className="flex flex-col gap-1 max-w-full">
{locations.map((loc, idx) => (
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
))}
</div>
);
export const SearchToolCall: React.FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { title, content, locations } = toolCall; const { title, content, locations } = toolCall;
const queryText = safeTitle(title); const queryText = safeTitle(title);
@@ -63,14 +180,14 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Error case: show search query + error in card layout // Error case: show search query + error in card layout
if (errors.length > 0) { if (errors.length > 0) {
return ( return (
<ToolCallCard icon="🔍"> <SearchCard status="error" isFirst={isFirst} isLast={isLast}>
<ToolCallRow label="Search"> <SearchRow label="Search">
<div className="font-mono">{queryText}</div> <div className="font-mono">{queryText}</div>
</ToolCallRow> </SearchRow>
<ToolCallRow label="Error"> <SearchRow label="Error">
<div className="text-[#c74e39] font-medium">{errors.join('\n')}</div> <div className="text-qwen-error font-medium">{errors.join('\n')}</div>
</ToolCallRow> </SearchRow>
</ToolCallCard> </SearchCard>
); );
} }
@@ -80,28 +197,27 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// If multiple results, use card layout; otherwise use compact format // If multiple results, use card layout; otherwise use compact format
if (locations.length > 1) { if (locations.length > 1) {
return ( return (
<ToolCallCard icon="🔍"> <SearchCard status={containerStatus} isFirst={isFirst} isLast={isLast}>
<ToolCallRow label="Search"> <SearchRow label="Search">
<div className="font-mono">{queryText}</div> <div className="font-mono">{queryText}</div>
</ToolCallRow> </SearchRow>
<ToolCallRow label={`Found (${locations.length})`}> <SearchRow label={`Found (${locations.length})`}>
<LocationsList locations={locations} /> <LocationsListLocal locations={locations} />
</ToolCallRow> </SearchRow>
</ToolCallCard> </SearchCard>
); );
} }
// Single result - compact format // Single result - compact format
return ( return (
<ToolCallContainer <InlineContainer
label="Search"
status={containerStatus} status={containerStatus}
className="search-toolcall"
labelSuffix={`(${queryText})`} labelSuffix={`(${queryText})`}
isFirst={isFirst}
isLast={isLast}
> >
{/* <span className="font-mono">{queryText}</span> */}
<span className="mx-2 opacity-50"></span> <span className="mx-2 opacity-50"></span>
<LocationsList locations={locations} /> <LocationsListLocal locations={locations} />
</ToolCallContainer> </InlineContainer>
); );
} }
@@ -109,11 +225,11 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
if (textOutputs.length > 0) { if (textOutputs.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status); const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return ( return (
<ToolCallContainer <InlineContainer
label="Search"
status={containerStatus} status={containerStatus}
className="search-toolcall"
labelSuffix={queryText ? `(${queryText})` : undefined} labelSuffix={queryText ? `(${queryText})` : undefined}
isFirst={isFirst}
isLast={isLast}
> >
<div className="flex flex-col"> <div className="flex flex-col">
{textOutputs.map((text, index) => ( {textOutputs.map((text, index) => (
@@ -126,7 +242,7 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
</div> </div>
))} ))}
</div> </div>
</ToolCallContainer> </InlineContainer>
); );
} }
@@ -134,13 +250,13 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
if (queryText) { if (queryText) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status); const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return ( return (
<ToolCallContainer <InlineContainer
label="Search"
status={containerStatus} status={containerStatus}
className="search-toolcall" isFirst={isFirst}
isLast={isLast}
> >
<span className="font-mono">{queryText}</span> <span className="font-mono">{queryText}</span>
</ToolCallContainer> </InlineContainer>
); );
} }

View File

@@ -47,10 +47,8 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
<div <div
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`} className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
> >
{/* Timeline connector line using ::after pseudo-element */} <div className="toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
{/* TODO: gap-0 */} <div className="flex items-baseline gap-1 relative min-w-0">
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center gap-1 relative min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]"> <span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label} {label}
</span> </span>

View File

@@ -56,6 +56,7 @@ export interface ToolCallData {
*/ */
export interface BaseToolCallProps { export interface BaseToolCallProps {
toolCall: ToolCallData; toolCall: ToolCallData;
// Optional timeline flags for rendering connector line cropping
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
} }

View File

@@ -13,7 +13,9 @@ import { BaseMessageHandler } from './BaseMessageHandler.js';
*/ */
export class SettingsMessageHandler extends BaseMessageHandler { export class SettingsMessageHandler extends BaseMessageHandler {
canHandle(messageType: string): boolean { canHandle(messageType: string): boolean {
return ['openSettings', 'recheckCli'].includes(messageType); return ['openSettings', 'recheckCli', 'setApprovalMode'].includes(
messageType,
);
} }
async handle(message: { type: string; data?: unknown }): Promise<void> { async handle(message: { type: string; data?: unknown }): Promise<void> {
@@ -26,6 +28,14 @@ export class SettingsMessageHandler extends BaseMessageHandler {
await this.handleRecheckCli(); await this.handleRecheckCli();
break; break;
case 'setApprovalMode':
await this.handleSetApprovalMode(
message.data as {
modeId?: 'plan' | 'default' | 'auto-edit' | 'yolo';
},
);
break;
default: default:
console.warn( console.warn(
'[SettingsMessageHandler] Unknown message type:', '[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}` },
});
}
}
} }

View File

@@ -70,26 +70,32 @@ export const useMessageHandling = () => {
/** /**
* Add stream chunk * Add stream chunk
*/ */
const appendStreamChunk = useCallback((chunk: string) => { const appendStreamChunk = useCallback(
setMessages((prev) => { (chunk: string) => {
let idx = streamingMessageIndexRef.current; // Ignore late chunks after user cancelled streaming (until next streamStart)
const next = prev.slice(); if (!isStreaming) return;
// If there is no active placeholder (e.g., after a tool call), start a new one setMessages((prev) => {
if (idx === null) { let idx = streamingMessageIndexRef.current;
idx = next.length; const next = prev.slice();
streamingMessageIndexRef.current = idx;
next.push({ role: 'assistant', content: '', timestamp: Date.now() });
}
if (idx < 0 || idx >= next.length) { // If there is no active placeholder (e.g., after a tool call), start a new one
return prev; if (idx === null) {
} idx = next.length;
const target = next[idx]; streamingMessageIndexRef.current = idx;
next[idx] = { ...target, content: (target.content || '') + chunk }; next.push({ role: 'assistant', content: '', timestamp: Date.now() });
return next; }
});
}, []); 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) * Break current assistant stream segment (e.g., when a tool call starts/updates)
@@ -150,6 +156,8 @@ export const useMessageHandling = () => {
endStreaming, endStreaming,
// Thought handling // Thought handling
appendThinkingChunk: (chunk: string) => { appendThinkingChunk: (chunk: string) => {
// Ignore late thoughts after user cancelled streaming
if (!isStreaming) return;
setMessages((prev) => { setMessages((prev) => {
let idx = thinkingMessageIndexRef.current; let idx = thinkingMessageIndexRef.current;
const next = prev.slice(); const next = prev.slice();

View File

@@ -43,6 +43,11 @@ export default {
ivory: '#f5f5ff', ivory: '#f5f5ff',
slate: '#141420', slate: '#141420',
green: '#6bcf7f', green: '#6bcf7f',
// Status colors used by toolcall components
success: '#74c991',
error: '#c74e39',
warning: '#e1c08d',
loading: 'var(--app-secondary-foreground)',
}, },
}, },
borderRadius: { borderRadius: {