qwen-code/packages/vscode-ide-companion/src/webview/App.tsx
易良 e49867a762
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
feat(vscode): replace OAuth with Coding Plan / API Key provider setup (#3398)
* refactor(core): move codingPlan constants from cli to core package

Extract Coding Plan region configs, model templates, and utility
functions into packages/core/src/constants/ so both CLI and VSCode
extension can import from a shared source of truth.

* refactor(cli): import codingPlan constants from core instead of local path

Update all CLI files to import CodingPlanRegion, CODING_PLAN_ENV_KEY,
and related utilities from @qwen-code/qwen-code-core, replacing the
local ../../constants/codingPlan.js imports.

* feat(vscode-ide-companion): replace login flow with provider setup via VSCode Settings

Replace the OAuth-based login command with a settings-driven provider
configuration flow. Users now configure Coding Plan or API Key providers
through VSCode Settings (qwen-code.*), which auto-syncs to
~/.qwen/settings.json.

- Rename login command to auth, opening VSCode Settings panel
- Add /auth2 interactive flow (QuickPick + InputBox)
- Add ProviderSetupForm onboarding component with inline config
- Add bidirectional sync between VSCode settings and ~/.qwen/settings.json
- Add settingsWriter service for direct settings.json read/write
- Add VSCode configuration schema (provider, apiKey, region, model, etc.)
- Update all login/session messages to use auth terminology

* refactor(vscode-ide-companion): rename auth2→auth, remove dead code, fix sync guard

- Rename auth2 to auth for all message types, handlers, and slash command
- Remove unused InfoBanner.tsx (128 lines, no references)
- Remove dead openProviderSettings handler (no callers)
- Remove redundant qwen-code.baseUrl VSCode setting (already in modelProviders)
- Replace unreliable setTimeout(500) sync guard with await Promise.all + finally
- Clean up old authHandler/setAuthHandler in favor of authInteractiveHandler

* refactor(vscode-ide-companion): remove dead VSCode Settings plumbing, simplify sync

- Remove qwen-code.modelProviders and qwen-code.model from package.json
  (model switching handled by chat UI's /model command, not VSCode Settings)
- Remove connectWithSettings message handler and plumbing
  (no webview component sends this message type)
- Remove handleConnectWithSettings method from WebViewProvider
- Simplify syncVSCodeSettingsToQwenConfig: only sync provider/apiKey/region
- Simplify syncQwenConfigToVSCodeSettings: only populate provider/apiKey/region
- Simplify QwenSettingsForVSCode interface: remove modelProviders and model
- Improve Onboarding UI: logo above card, better hierarchy, arrow icon on button

* fix(vscode-ide-companion): add missing vscode.workspace mock in test

Add onDidChangeConfiguration and getConfiguration to the vscode.workspace
mock in WebViewProvider.test.ts to fix CI test failures.

* fix(vscode-ide-companion): clean up stale coding plan state, add auth cancel handling, add tests

- Clear CODING_PLAN_ENV_KEY and codingPlan metadata when switching to api-key mode
- Add authCancelled notification when QuickPick/InputBox is dismissed
- ProviderSetupForm resets button state on authCancelled
- syncVSCodeSettingsToQwenConfig returns false for api-key mode (no-op)
- Fix Onboarding vertical centering (flex-1 min-h-0)
- Import from @qwen-code/qwen-code-core top-level instead of deep paths
- Add tests: settingsWriter, ProviderSetupForm cancel, AuthMessageHandler cancel, WebViewProvider sync
- Fix redundant ternary in pick() helper

* fix(vscode-ide-companion): force center Onboarding against parent override

Parent container uses [&>*]:items-start and [&>*]:text-left which overrides
Tailwind classes. Use inline style for alignItems/justifyContent/textAlign
to ensure Onboarding is always centered both horizontally and vertically.

* fix(vscode-ide-companion): bundle onboarding logo

* test(vscode-ide-companion): add png loader to bundle test

* fix(vscode-ide-companion/webview): avoid redundant auth sync reconnects

* fix(vscode-ide-companion/webview): fix auth sync typecheck

* docs(vscode-ide-companion): clarify auth restoration flow

* fix(webui): use bracket access for permission drawer plan content

* fix(vscode-ide-companion): guard authSuccess emission on actual auth state

After reconnecting in handleAuthInteractive, doInitializeAgentConnection
may return without throwing even when credentials are rejected (it sends
authState:false internally and returns early). Previously we unconditionally
emitted authSuccess, which contradicted the failed auth state and could
briefly show a success toast before re-opening the auth flow.

Now we check this.authState after reconnection: only emit authSuccess when
authentication actually succeeded, otherwise emit authError with a clear
credentials message.

Addresses review feedback from PR #3398.

* fix(vscode): address auth setup review feedback

* fix(vscode-ide-companion): guard concurrent auth flows, merge model providers

- Add authFlowActive mutex and autoAuthTimer to WebViewProvider so
  startInteractiveAuth() cancels the deferred auto-auth timeout,
  preventing two overlapping QuickPick flows from a single command.
- Change writeModelProvidersConfig() to merge new entries with existing
  non-target models (different envKey) instead of replacing the entire
  array, preserving unrelated providers like Coding Plan.

* fix(vscode-ide-companion): handle apiKey clearing as de-auth signal, fix auto-auth race, clean imports

- Add clearPersistedAuth() to settingsWriter.ts: removes selectedType,
  API keys, and coding plan metadata from ~/.qwen/settings.json
- Config change handler now detects empty apiKey with active agent and
  triggers de-auth: clear credentials, disconnect, update authState
- Auto-auth timer callback now properly sets authFlowActive mutex to
  prevent concurrent auth flows with startInteractiveAuth()
- Add test covering the de-auth path (clearPersistedAuth + disconnect)
- Fix import formatting in 7 CLI files (spacing, trailing commas)
- Remove duplicate comment in attemptAuthStateRestoration()

* fix(vscode-ide-companion): scope de-auth to apiKey changes only

The previous de-auth logic triggered on any auth-related setting change
where syncVSCodeSettingsToQwenConfig() returned false. For api-key
providers this is the normal path (interactive auth owns config), so
changing codingPlanRegion or provider would incorrectly wipe OPENAI_API_KEY.

Now the de-auth branch only fires when e.affectsConfiguration('qwen-code.apiKey')
is true AND the value is empty, preventing false-positive credential clearing.

Add regression test: non-apiKey setting changes on an api-key provider
must not trigger clearPersistedAuth or disconnect.

* fix(vscode-ide-companion): add disconnect to mock type to fix CI typecheck

The hoisted mockQwenAgentManagerInstances type was missing the
disconnect property, causing TS2339 in the de-auth test assertions.
2026-04-21 22:20:58 +08:00

1192 lines
39 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
useLayoutEffect,
} from 'react';
import { useVSCode } from './hooks/useVSCode.js';
import { useSessionManagement } from './hooks/session/useSessionManagement.js';
import { useFileContext } from './hooks/file/useFileContext.js';
import { useMessageHandling } from './hooks/message/useMessageHandling.js';
import { useToolCalls } from './hooks/useToolCalls.js';
import { useWebViewMessages } from './hooks/useWebViewMessages.js';
import {
shouldSendMessage,
useMessageSubmit,
} from './hooks/useMessageSubmit.js';
import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui';
import type { TextMessage } from './hooks/message/useMessageHandling.js';
import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js';
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
import { hasToolCallOutput } from './utils/utils.js';
import { Onboarding } from './components/layout/Onboarding.js';
import { type CompletionItem } from '../types/completionItemTypes.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
import {
AssistantMessage,
UserMessage,
ThinkingMessage,
WaitingMessage,
InterruptedMessage,
FileIcon,
PermissionDrawer,
AskUserQuestionDialog,
InsightProgressCard,
ImageMessageRenderer,
ImagePreview,
// Layout components imported directly from webui
EmptyState,
ChatHeader,
SessionSelector,
} from '@qwen-code/webui';
import { InputForm } from './components/layout/InputForm.js';
import {
AccountInfoDialog,
type AccountInfo,
} from './components/AccountInfoDialog.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js';
import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk';
import type { Question } from '../types/acpTypes.js';
import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js';
import { computeContextUsage } from './utils/contextUsage.js';
/**
* Memoized message list that only re-renders when messages or callbacks change,
* not on every keystroke in the input field.
*/
interface MessageListItem {
type: 'message' | 'in-progress-tool-call' | 'completed-tool-call';
data: TextMessage | ToolCallData;
timestamp: number;
}
interface MessageListProps {
allMessages: MessageListItem[];
onFileClick: (path: string) => void;
}
const MessageList = React.memo<MessageListProps>(
({ allMessages, onFileClick }) => {
let imageIndex = 0;
return (
<>
{allMessages.map((item, index) => {
switch (item.type) {
case 'message': {
const msg = item.data as TextMessage;
if (msg.kind === 'image' && msg.imagePath) {
imageIndex += 1;
return (
<ImageMessageRenderer
key={`message-${index}`}
msg={msg as WebViewImageMessage}
imageIndex={imageIndex}
/>
);
}
if (msg.role === 'thinking') {
return (
<ThinkingMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
/>
);
}
if (msg.role === 'user') {
return (
<UserMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={onFileClick}
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={onFileClick}
/>
);
}
}
case 'in-progress-tool-call':
case 'completed-tool-call': {
return (
<ToolCall
key={`toolcall-${(item.data as ToolCallData).toolCallId}-${item.type}`}
toolCall={item.data as ToolCallData}
/>
);
}
default:
return null;
}
})}
</>
);
},
);
MessageList.displayName = 'MessageList';
export const App: React.FC = () => {
const vscode = useVSCode();
// Core hooks
const sessionManagement = useSessionManagement(vscode);
const fileContext = useFileContext(vscode);
const messageHandling = useMessageHandling();
const {
inProgressToolCalls,
completedToolCalls,
handleToolCallUpdate,
clearToolCalls,
} = useToolCalls();
// UI state
const [inputText, setInputText] = useState('');
const [permissionRequest, setPermissionRequest] = useState<{
options: PermissionOption[];
toolCall: PermissionToolCall;
} | null>(null);
const [askUserQuestionRequest, setAskUserQuestionRequest] = useState<{
questions: Question[];
sessionId: string;
metadata?: {
source?: string;
};
} | null>(null);
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); // Track if we're still initializing/loading
const [modelInfo, setModelInfo] = useState<ModelInfo | null>(null);
const [usageStats, setUsageStats] = useState<UsageStatsPayload | null>(null);
const [availableCommands, setAvailableCommands] = useState<
AvailableCommand[]
>([]);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [insightProgress, setInsightProgress] = useState<{
stage: string;
progress: number;
detail?: string;
} | null>(null);
const [insightReportPath, setInsightReportPath] = useState<string | null>(
null,
);
const [showModelSelector, setShowModelSelector] = useState(false);
const [accountInfo, setAccountInfo] = useState<AccountInfo | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
// Scroll container for message list; used to keep the view anchored to the latest content
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const inputFieldRef = useRef<HTMLDivElement | null>(null);
const [editMode, setEditMode] = useState<ApprovalModeValue>(
ApprovalMode.DEFAULT,
);
const [thinkingEnabled, setThinkingEnabled] = useState(false);
const [isComposing, setIsComposing] = useState(false);
// When true, do NOT auto-attach the active editor file/selection to message context
const [skipAutoActiveContext, setSkipAutoActiveContext] = useState(false);
// Completion system
const getCompletionItems = React.useCallback(
async (trigger: '@' | '/', query: string): Promise<CompletionItem[]> => {
if (trigger === '@') {
console.log('[App] getCompletionItems @ called', {
query,
requested: fileContext.hasRequestedFiles,
workspaceFiles: fileContext.workspaceFiles.length,
});
// Always trigger request based on current query, let the hook decide if an actual request is needed
fileContext.requestWorkspaceFiles(query);
const fileIcon = <FileIcon />;
const allItems: CompletionItem[] = fileContext.workspaceFiles.map(
(file) => ({
id: file.id,
label: file.label,
description: file.description,
type: 'file' as const,
icon: fileIcon,
// Insert filename after @, keep path for mapping
value: file.label,
path: file.path,
}),
);
// Fuzzy search is handled by the backend (FileSearchFactory)
// No client-side filtering needed - results are already fuzzy-matched
// If first time and still loading, show a placeholder
if (allItems.length === 0 && query && query.length >= 1) {
return [
{
id: 'loading-files',
label: 'Searching files…',
description: 'Type to filter, or wait a moment…',
type: 'info' as const,
},
];
}
return allItems;
} else {
// Handle slash commands with grouping
// Model group - special items without / prefix
const modelGroupItems: CompletionItem[] = [
{
id: 'model',
label: 'Switch model...',
description: modelInfo?.name || 'Default',
type: 'command',
group: 'Model',
},
];
// Account group
const accountGroupItems: CompletionItem[] = [
{
id: 'auth',
label: '/auth',
description: 'Configure Coding Plan or API Key',
type: 'command',
group: 'Account',
},
{
id: 'account',
label: 'Account',
description: 'Show current account and authentication info',
type: 'command',
group: 'Account',
},
];
// Slash Commands group - commands from server (available_commands_update)
const slashCommandItems: CompletionItem[] = availableCommands.map(
(cmd) => ({
id: cmd.name,
label: `/${cmd.name}`,
description: cmd.description,
type: 'command' as const,
group: 'Slash Commands',
value: cmd.name,
}),
);
// Combine all commands
const allCommands = [
...modelGroupItems,
...accountGroupItems,
...slashCommandItems,
];
// Filter by query
const lowerQuery = query.toLowerCase();
return allCommands.filter(
(cmd) =>
cmd.label.toLowerCase().includes(lowerQuery) ||
(cmd.description &&
cmd.description.toLowerCase().includes(lowerQuery)),
);
}
},
[fileContext, availableCommands, modelInfo?.name],
);
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
const contextUsage = useMemo(
() => computeContextUsage(usageStats, modelInfo),
[usageStats, modelInfo],
);
// Track a lightweight signature of workspace files to detect content changes even when length is unchanged
const workspaceFilesSignature = useMemo(
() =>
fileContext.workspaceFiles
.map(
(file) =>
`${file.id}|${file.label}|${file.description ?? ''}|${file.path}`,
)
.join('||'),
[fileContext.workspaceFiles],
);
// When workspace files update while menu open for @, refresh items to reflect latest search results.
// Note: Avoid depending on the entire `completion` object here, since its identity
// changes on every render which would retrigger this effect and can cause a refresh loop.
useEffect(() => {
if (completion.isOpen && completion.triggerChar === '@') {
// Only refresh items; do not change other completion state to avoid re-renders loops
completion.refreshCompletion();
}
// Only re-run when the actual data source changes, not on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
workspaceFilesSignature,
completion.isOpen,
completion.triggerChar,
completion.query,
]);
const { attachedImages, handleRemoveImage, clearImages, handlePaste } =
useImagePaste({
onError: (error) => {
console.error('Paste error:', error);
},
});
const { handleSubmit: submitMessage } = useMessageSubmit({
inputText,
setInputText,
attachedImages,
clearImages,
messageHandling,
fileContext,
skipAutoActiveContext,
vscode,
inputFieldRef,
isStreaming: messageHandling.isStreaming,
isWaitingForResponse: messageHandling.isWaitingForResponse,
});
const canSubmit = shouldSendMessage({
inputText,
attachedImages,
isStreaming: messageHandling.isStreaming,
isWaitingForResponse: messageHandling.isWaitingForResponse,
});
// Handle cancel/stop from the input bar
// Emit a cancel to the extension and immediately reflect interruption locally.
const handleCancel = useCallback(() => {
if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) {
// End streaming state and add an 'Interrupted' line.
// IMPORTANT: Do NOT clear isWaitingForResponse here — let the
// extension's streamEnd message clear it after the cancel is
// properly processed on the backend. This keeps the submit
// guard active and prevents any cached input from being
// auto-submitted during the cancel → confirmed window.
if (messageHandling.isStreaming) {
try {
messageHandling.endStreaming?.();
} catch {
/* no-op */
}
messageHandling.addMessage({
role: 'assistant',
content: 'Interrupted',
timestamp: Date.now(),
});
}
}
// Notify extension/agent to cancel server-side work
vscode.postMessage({
type: 'cancelStreaming',
data: {},
});
}, [messageHandling, vscode]);
// Message handling
useWebViewMessages({
sessionManagement,
fileContext,
messageHandling,
handleToolCallUpdate,
clearToolCalls,
setPlanEntries,
handlePermissionRequest: setPermissionRequest,
handleAskUserQuestion: setAskUserQuestionRequest,
inputFieldRef,
setInputText,
setEditMode,
setIsAuthenticated,
setUsageStats: (stats) => setUsageStats(stats ?? null),
setModelInfo: (info) => {
setModelInfo(info);
},
setAvailableCommands: (commands) => {
setAvailableCommands(commands);
},
setAvailableModels: (models) => {
setAvailableModels(models);
},
setAccountInfo: (info) => {
setAccountInfo(info);
},
setInsightReportPath,
setInsightProgress,
});
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
// but don't interrupt the user if they scrolled up.
// We track whether the user is currently "pinned" to the bottom (near the end).
const [pinnedToBottom, setPinnedToBottom] = useState(true);
const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 });
// Observe scroll position to know if user has scrolled away from the bottom.
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) {
return;
}
const onScroll = () => {
// Use a small threshold so slight deltas don't flip the state.
// Note: there's extra bottom padding for the input area, so keep this a bit generous.
const threshold = 80; // px tolerance
const distanceFromBottom =
container.scrollHeight - (container.scrollTop + container.clientHeight);
setPinnedToBottom(distanceFromBottom <= threshold);
};
// Initialize once mounted so first render is correct
onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, []);
// When content changes, if the user is pinned to bottom, keep it anchored there.
// Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates.
useLayoutEffect(() => {
const container = messagesContainerRef.current;
if (!container) {
return;
}
// Detect whether new items were appended (vs. streaming chunk updates)
const prev = prevCountsRef.current;
const newMsg = messageHandling.messages.length > prev.msgLen;
const newInProg = inProgressToolCalls.length > prev.inProgLen;
const newDone = completedToolCalls.length > prev.doneLen;
prevCountsRef.current = {
msgLen: messageHandling.messages.length,
inProgLen: inProgressToolCalls.length,
doneLen: completedToolCalls.length,
};
if (!pinnedToBottom) {
// Do nothing if user scrolled away; avoid stealing scroll.
return;
}
const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks
// Anchor to the bottom on next frame to avoid layout thrash.
const raf = requestAnimationFrame(() => {
const top = container.scrollHeight - container.clientHeight;
// Use scrollTo to avoid cross-context issues with scrollIntoView.
container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
});
return () => cancelAnimationFrame(raf);
}, [
pinnedToBottom,
messageHandling.messages,
inProgressToolCalls,
completedToolCalls,
messageHandling.isWaitingForResponse,
messageHandling.loadingMessage,
messageHandling.isStreaming,
planEntries,
]);
// When the last rendered item resizes (e.g., images/code blocks load/expand),
// if we're pinned to bottom, keep it anchored there.
useEffect(() => {
const container = messagesContainerRef.current;
const endEl = messagesEndRef.current;
if (!container || !endEl) {
return;
}
const lastItem = endEl.previousElementSibling as HTMLElement | null;
if (!lastItem) {
return;
}
let frame = 0;
const ro = new ResizeObserver(() => {
if (!pinnedToBottom) {
return;
}
// Defer to next frame to avoid thrash during rapid size changes
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const top = container.scrollHeight - container.clientHeight;
container.scrollTo({ top });
});
});
ro.observe(lastItem);
return () => {
cancelAnimationFrame(frame);
ro.disconnect();
};
}, [
pinnedToBottom,
messageHandling.messages,
inProgressToolCalls,
completedToolCalls,
]);
// Set loading state to false after initial mount and when we have authentication info
useEffect(() => {
if (isAuthenticated !== null) {
setIsLoading(false);
return;
}
// Safety-net timeout: if initialization takes too long (e.g. CLI crashed
// before the error could be surfaced), stop the spinner and let the user
// see the onboarding / error UI instead of hanging forever.
const timeout = setTimeout(() => {
setIsLoading(false);
}, 30_000);
return () => clearTimeout(timeout);
}, [isAuthenticated]);
// Handle permission response
const handlePermissionResponse = useCallback(
(optionId: string) => {
// Forward the selected optionId directly to extension as ACP permission response
// Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc.
vscode.postMessage({
type: 'permissionResponse',
data: { optionId },
});
setPermissionRequest(null);
},
[vscode],
);
// Handle ask user question response
const handleAskUserQuestionResponse = useCallback(
(answers: Record<string, string>) => {
// Forward answers to extension as ACP permission response
vscode.postMessage({
type: 'askUserQuestionResponse',
data: { answers },
});
setAskUserQuestionRequest(null);
},
[vscode],
);
// Handle ask user question cancel
const handleAskUserQuestionCancel = useCallback(() => {
// Forward cancel to extension as ACP permission response with cancel option
vscode.postMessage({
type: 'askUserQuestionResponse',
data: { answers: {}, cancelled: true },
});
setAskUserQuestionRequest(null);
}, [vscode]);
// Handle completion selection.
// When fillOnly is true (Tab), slash commands are inserted into the input
// instead of being sent immediately, so users can append arguments.
const handleCompletionSelect = useCallback(
(item: CompletionItem, fillOnly?: boolean) => {
// Handle completion selection by inserting the value into the input field
const inputElement = inputFieldRef.current;
if (!inputElement) {
return;
}
// Ignore info items (placeholders like "Searching files…")
if (item.type === 'info') {
completion.closeCompletion();
return;
}
// Commands can execute immediately
if (item.type === 'command') {
const itemId = item.id;
// Helper to clear trigger text from input
const clearTriggerText = () => {
const text = inputElement.textContent || '';
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
// Fallback: just clear everything
inputElement.textContent = '';
setInputText('');
return;
}
// Find and remove the slash command trigger
const range = selection.getRangeAt(0);
let cursorPos = text.length;
if (range.startContainer === inputElement) {
const childIndex = range.startOffset;
let offset = 0;
for (
let i = 0;
i < childIndex && i < inputElement.childNodes.length;
i++
) {
offset += inputElement.childNodes[i].textContent?.length || 0;
}
cursorPos = offset || text.length;
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
const walker = document.createTreeWalker(
inputElement,
NodeFilter.SHOW_TEXT,
null,
);
let offset = 0;
let found = false;
let node: Node | null = walker.nextNode();
while (node) {
if (node === range.startContainer) {
offset += range.startOffset;
found = true;
break;
}
offset += node.textContent?.length || 0;
node = walker.nextNode();
}
cursorPos = found ? offset : text.length;
}
const textBeforeCursor = text.substring(0, cursorPos);
const slashPos = textBeforeCursor.lastIndexOf('/');
if (slashPos >= 0) {
const newText =
text.substring(0, slashPos) + text.substring(cursorPos);
inputElement.textContent = newText;
setInputText(newText);
}
};
if (itemId === 'auth') {
clearTriggerText();
vscode.postMessage({ type: 'auth', data: {} });
completion.closeCompletion();
return;
}
if (itemId === 'account') {
clearTriggerText();
vscode.postMessage({ type: 'getAccountInfo', data: {} });
completion.closeCompletion();
return;
}
if (itemId === 'model') {
clearTriggerText();
setShowModelSelector(true);
completion.closeCompletion();
return;
}
// Handle server-provided slash commands by sending them as messages.
// Skip when fillOnly (Tab) — let the generic insertion path fill the
// command text so the user can keep typing arguments.
const serverCmd = availableCommands.find((c) => c.name === itemId);
if (serverCmd && !fillOnly) {
// Clear the trigger text since we're sending the command
clearTriggerText();
// Send the slash command as a user message
vscode.postMessage({
type: 'sendMessage',
data: { text: `/${serverCmd.name}` },
});
completion.closeCompletion();
return;
}
}
// If selecting a file, add @filename -> fullpath mapping
if (item.type === 'file' && item.value && item.path) {
try {
fileContext.addFileReference(item.value, item.path);
} catch (err) {
console.warn('[App] addFileReference failed:', err);
}
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
// Current text and cursor
const text = inputElement.textContent || '';
const range = selection.getRangeAt(0);
// Compute total text offset for contentEditable
let cursorPos = text.length;
if (range.startContainer === inputElement) {
const childIndex = range.startOffset;
let offset = 0;
for (
let i = 0;
i < childIndex && i < inputElement.childNodes.length;
i++
) {
offset += inputElement.childNodes[i].textContent?.length || 0;
}
cursorPos = offset || text.length;
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
const walker = document.createTreeWalker(
inputElement,
NodeFilter.SHOW_TEXT,
null,
);
let offset = 0;
let found = false;
let node: Node | null = walker.nextNode();
while (node) {
if (node === range.startContainer) {
offset += range.startOffset;
found = true;
break;
}
offset += node.textContent?.length || 0;
node = walker.nextNode();
}
cursorPos = found ? offset : text.length;
}
// Replace from trigger to cursor with selected value
const textBeforeCursor = text.substring(0, cursorPos);
const atPos = textBeforeCursor.lastIndexOf('@');
// Only consider slash as trigger if we're in slash command mode
const slashPos =
completion.triggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1;
const triggerPos = Math.max(atPos, slashPos);
if (triggerPos >= 0) {
const insertValue =
typeof item.value === 'string' ? item.value : String(item.label);
const newText =
text.substring(0, triggerPos + 1) + // keep the trigger symbol
insertValue +
' ' +
text.substring(cursorPos);
// Update DOM and state, and move caret to end
inputElement.textContent = newText;
setInputText(newText);
const newRange = document.createRange();
const sel = window.getSelection();
newRange.selectNodeContents(inputElement);
newRange.collapse(false);
sel?.removeAllRanges();
sel?.addRange(newRange);
}
// Close the completion menu
completion.closeCompletion();
},
[
completion,
inputFieldRef,
setInputText,
fileContext,
vscode,
availableCommands,
],
);
// Handle model selection
const handleModelSelect = useCallback(
(modelId: string) => {
vscode.postMessage({
type: 'setModel',
data: { modelId },
});
},
[vscode],
);
// Handle attach context click
const handleAttachContextClick = useCallback(() => {
// Open native file picker (different from '@' completion which searches workspace files)
vscode.postMessage({
type: 'attachFile',
data: {},
});
}, [vscode]);
const handleOpenInsightReport = useCallback(() => {
if (!insightReportPath) {
return;
}
vscode.postMessage({
type: 'openInsightReport',
data: { path: insightReportPath },
});
}, [insightReportPath, vscode]);
// Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default)
const handleToggleEditMode = useCallback(() => {
setEditMode((prev) => {
const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev];
try {
vscode.postMessage({
type: 'setApprovalMode',
data: { modeId: next },
});
} catch {
/* no-op */
}
return next;
});
}, [vscode]);
// Handle Tab key to cycle approval modes when input is focused
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (
e.key === 'Tab' &&
!e.shiftKey &&
!isComposing &&
!completion.isOpen
) {
e.preventDefault();
handleToggleEditMode();
}
},
[completion.isOpen, handleToggleEditMode, isComposing],
);
const handleToggleThinking = useCallback(() => {
setThinkingEnabled((prev) => !prev);
}, []);
// When user sends a message after scrolling up, re-pin and jump to the bottom
const handleSubmitWithScroll = useCallback(
(e: React.FormEvent | React.KeyboardEvent, explicitText?: string) => {
setPinnedToBottom(true);
const container = messagesContainerRef.current;
if (container) {
const top = container.scrollHeight - container.clientHeight;
container.scrollTo({ top });
}
submitMessage(e, explicitText);
},
[submitMessage],
);
// 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 ?? 0,
}));
// Completed tool calls
const completedTools = completedToolCalls
.filter(hasToolCallOutput)
.map((toolCall) => ({
type: 'completed-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp ?? 0,
}));
// 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]);
const handleFileClick = useCallback(
(path: string): void => {
vscode.postMessage({
type: 'openFile',
data: { path },
});
},
[vscode],
);
const hasContent =
messageHandling.messages.length > 0 ||
messageHandling.isStreaming ||
inProgressToolCalls.length > 0 ||
completedToolCalls.length > 0 ||
planEntries.length > 0 ||
allMessages.length > 0;
return (
<div className="chat-container relative">
{/* Top-level loading overlay */}
{isLoading && (
<div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground text-sm">
Preparing Qwen Code...
</p>
</div>
</div>
)}
<SessionSelector
visible={sessionManagement.showSessionSelector}
sessions={sessionManagement.filteredSessions}
currentSessionId={sessionManagement.currentSessionId}
searchQuery={sessionManagement.sessionSearchQuery}
onSearchChange={sessionManagement.setSessionSearchQuery}
onSelectSession={(sessionId: string) => {
sessionManagement.handleSwitchSession(sessionId);
sessionManagement.setSessionSearchQuery('');
}}
onClose={() => sessionManagement.setShowSessionSelector(false)}
hasMore={sessionManagement.hasMore}
isLoading={sessionManagement.isLoading}
onLoadMore={sessionManagement.handleLoadMoreSessions}
/>
<ChatHeader
currentSessionTitle={sessionManagement.currentSessionTitle}
onLoadSessions={sessionManagement.handleLoadQwenSessions}
onNewSession={() =>
sessionManagement.handleNewQwenSession(modelInfo?.modelId ?? null)
}
/>
<div
ref={messagesContainerRef}
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
>
{!hasContent && !isLoading ? (
isAuthenticated === false ? (
<Onboarding />
) : isAuthenticated === null ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<span
className="inline-block w-6 h-6 animate-spin rounded-full border-2"
style={{
borderColor: 'var(--app-secondary-foreground)',
borderTopColor: 'transparent',
}}
/>
<span
className="text-sm"
style={{ color: 'var(--app-secondary-foreground)' }}
>
Preparing Qwen Code...
</span>
</div>
) : (
<EmptyState isAuthenticated />
)
) : (
<>
{/* Render all messages and tool calls */}
<MessageList
allMessages={allMessages}
onFileClick={handleFileClick}
/>
{insightProgress && (
<InsightProgressCard
stage={insightProgress.stage}
progress={insightProgress.progress}
detail={insightProgress.detail}
/>
)}
{insightReportPath && (
<div className="px-[30px] py-2">
<div className="text-sm text-[var(--vscode-descriptionForeground)]">
Insight report generated at:
</div>
<a
href="#"
className="mt-1 inline-block break-all text-sm text-[var(--vscode-textLink-foreground)] underline decoration-[color-mix(in_srgb,var(--vscode-textLink-foreground)_55%,transparent)] underline-offset-2 hover:text-[var(--vscode-textLink-activeForeground)]"
onClick={(event) => {
event.preventDefault();
handleOpenInsightReport();
}}
>
{insightReportPath}
</a>
</div>
)}
{/* Waiting message positioned fixed above the input form to avoid layout shifts */}
{messageHandling.isWaitingForResponse &&
messageHandling.loadingMessage && (
<div className="waiting-message-slot min-h-[28px]">
<WaitingMessage
loadingMessage={messageHandling.loadingMessage}
/>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{isAuthenticated && (
<InputForm
inputText={inputText}
inputFieldRef={inputFieldRef}
isStreaming={messageHandling.isStreaming}
isWaitingForResponse={messageHandling.isWaitingForResponse}
isComposing={isComposing}
editMode={editMode}
thinkingEnabled={thinkingEnabled}
activeFileName={fileContext.activeFileName}
activeSelection={fileContext.activeSelection}
skipAutoActiveContext={skipAutoActiveContext}
contextUsage={contextUsage}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={handleInputKeyDown}
onSubmit={handleSubmitWithScroll}
onCancel={handleCancel}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={fileContext.focusActiveEditor}
onToggleSkipAutoActiveContext={() =>
setSkipAutoActiveContext((v) => !v)
}
onShowCommandMenu={async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
const selection = window.getSelection();
let position = { top: 0, left: 0 };
if (selection && selection.rangeCount > 0) {
try {
const range = selection.getRangeAt(0);
const rangeRect = range.getBoundingClientRect();
if (rangeRect.top > 0 && rangeRect.left > 0) {
position = {
top: rangeRect.top,
left: rangeRect.left,
};
} else {
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} catch (error) {
console.error('[App] Error getting cursor position:', error);
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} else {
const inputRect = inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
await completion.openCompletion('/', '', position);
}
}}
onAttachContext={handleAttachContextClick}
onPaste={handlePaste}
completionIsOpen={completion.isOpen}
completionItems={completion.items}
onCompletionSelect={handleCompletionSelect}
onCompletionFill={(item) => handleCompletionSelect(item, true)}
onCompletionClose={completion.closeCompletion}
canSubmit={canSubmit}
extraContent={
attachedImages.length > 0 ? (
<ImagePreview
images={attachedImages}
onRemove={handleRemoveImage}
/>
) : null
}
showModelSelector={showModelSelector}
availableModels={availableModels}
currentModelId={modelInfo?.modelId}
onSelectModel={handleModelSelect}
onCloseModelSelector={() => setShowModelSelector(false)}
/>
)}
{isAuthenticated && permissionRequest && (
<PermissionDrawer
isOpen={!!permissionRequest}
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
onClose={() => setPermissionRequest(null)}
/>
)}
{isAuthenticated && askUserQuestionRequest && (
<AskUserQuestionDialog
questions={askUserQuestionRequest.questions}
onSubmit={handleAskUserQuestionResponse}
onCancel={handleAskUserQuestionCancel}
/>
)}
{accountInfo && (
<AccountInfoDialog
info={accountInfo}
onClose={() => setAccountInfo(null)}
/>
)}
</div>
);
};