feat(vscode-ide-companion): split module & notes in english

This commit is contained in:
yiliang114
2025-11-25 00:32:51 +08:00
parent 3cf22c065f
commit f503eb2520
42 changed files with 4189 additions and 3063 deletions

View File

@@ -27,9 +27,9 @@ import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
/**
* Qwen Agent管理器
* Qwen Agent Manager
*
* 协调各个模块,提供统一的接口
* Coordinates various modules and provides unified interface
*/
export class QwenAgentManager {
private connection: AcpConnection;
@@ -39,7 +39,7 @@ export class QwenAgentManager {
private sessionUpdateHandler: QwenSessionUpdateHandler;
private currentWorkingDir: string = process.cwd();
// 回调函数存储
// Callback storage
private callbacks: QwenAgentCallbacks = {};
constructor() {
@@ -49,7 +49,7 @@ export class QwenAgentManager {
this.connectionHandler = new QwenConnectionHandler();
this.sessionUpdateHandler = new QwenSessionUpdateHandler({});
// 设置ACP连接的回调
// Set ACP connection callbacks
this.connection.onSessionUpdate = (data: AcpSessionUpdate) => {
this.sessionUpdateHandler.handleSessionUpdate(data);
};
@@ -65,15 +65,15 @@ export class QwenAgentManager {
};
this.connection.onEndTurn = () => {
// 通知UI响应完成
// Notify UI response complete
};
}
/**
* 连接到Qwen服务
* Connect to Qwen service
*
* @param workingDir - 工作目录
* @param authStateManager - 认证状态管理器(可选)
* @param workingDir - Working directory
* @param authStateManager - Auth state manager (optional)
*/
async connect(
workingDir: string,
@@ -89,18 +89,18 @@ export class QwenAgentManager {
}
/**
* 发送消息
* Send message
*
* @param message - 消息内容
* @param message - Message content
*/
async sendMessage(message: string): Promise<void> {
await this.connection.sendPrompt(message);
}
/**
* 获取会话列表
* Get session list
*
* @returns 会话列表
* @returns Session list
*/
async getSessionList(): Promise<Array<Record<string, unknown>>> {
try {
@@ -129,10 +129,10 @@ export class QwenAgentManager {
}
/**
* 获取会话消息(从磁盘读取)
* Get session messages (read from disk)
*
* @param sessionId - 会话ID
* @returns 消息列表
* @param sessionId - Session ID
* @returns Message list
*/
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
try {
@@ -162,12 +162,12 @@ export class QwenAgentManager {
}
/**
* 通过发送 /chat save 命令保存会话
* 由于 CLI 不支持 session/save ACP 方法,我们直接发送 /chat save 命令
* Save session via /chat save command
* Since CLI doesn't support session/save ACP method, we send /chat save command directly
*
* @param sessionId - 会话ID
* @param tag - 保存标签
* @returns 保存响应
* @param sessionId - Session ID
* @param tag - Save tag
* @returns Save response
*/
async saveSessionViaCommand(
sessionId: string,
@@ -200,12 +200,12 @@ export class QwenAgentManager {
}
/**
* 通过 ACP session/save 方法保存会话 (已废弃CLI 不支持)
* Save session via ACP session/save method (deprecated, CLI doesn't support)
*
* @deprecated Use saveSessionViaCommand instead
* @param sessionId - 会话ID
* @param tag - 保存标签
* @returns 保存响应
* @param sessionId - Session ID
* @param tag - Save tag
* @returns Save response
*/
async saveSessionViaAcp(
sessionId: string,
@@ -219,11 +219,11 @@ export class QwenAgentManager {
}
/**
* 通过发送 /chat save 命令保存会话CLI 方式)
* 这会调用 CLI 的原生保存功能,确保保存的内容完整
* Save session via /chat save command (CLI way)
* Calls CLI's native save function to ensure complete content is saved
*
* @param tag - Checkpoint 标签
* @returns 保存结果
* @param tag - Checkpoint tag
* @returns Save result
*/
async saveCheckpointViaCommand(
tag: string,
@@ -263,13 +263,13 @@ export class QwenAgentManager {
}
/**
* 保存会话为 checkpoint使用 CLI 的格式)
* 保存到 ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
* 同时用 sessionId conversationId 保存两份,确保可以通过任一 ID 恢复
* Save session as checkpoint (using CLI format)
* Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json
* Saves two copies with sessionId and conversationId to ensure recovery via either ID
*
* @param messages - 当前会话消息
* @param messages - Current session messages
* @param conversationId - Conversation ID (from VSCode extension)
* @returns 保存结果
* @returns Save result
*/
async saveCheckpoint(
messages: ChatMessage[],
@@ -319,11 +319,11 @@ export class QwenAgentManager {
}
/**
* 直接保存会话到文件系统(不依赖 ACP
* Save session directly to file system (without relying on ACP)
*
* @param messages - 当前会话消息
* @param sessionName - 会话名称
* @returns 保存结果
* @param messages - Current session messages
* @param sessionName - Session name
* @returns Save result
*/
async saveSessionDirect(
messages: ChatMessage[],
@@ -335,11 +335,11 @@ export class QwenAgentManager {
}
/**
* 尝试通过 ACP session/load 方法加载会话
* 这是一个测试方法,用于验证 CLI 是否支持 session/load
* Try to load session via ACP session/load method
* This is a test method to verify if CLI supports session/load
*
* @param sessionId - 会话ID
* @returns 加载响应或错误
* @param sessionId - Session ID
* @returns Load response or error
*/
async loadSessionViaAcp(sessionId: string): Promise<unknown> {
try {
@@ -385,16 +385,16 @@ export class QwenAgentManager {
}
/**
* 直接从文件系统加载会话(不依赖 ACP
* Load session directly from file system (without relying on ACP)
*
* @param sessionId - 会话ID
* @returns 加载的会话消息或null
* @param sessionId - Session ID
* @returns Loaded session messages or null
*/
async loadSessionDirect(sessionId: string): Promise<ChatMessage[] | null> {
try {
console.log('[QwenAgentManager] Loading session directly:', sessionId);
// 加载会话
// Load session
const session = await this.sessionManager.loadSession(
sessionId,
this.currentWorkingDir,
@@ -405,7 +405,7 @@ export class QwenAgentManager {
return null;
}
// 转换消息格式
// Convert message format
const messages: ChatMessage[] = session.messages.map((msg) => ({
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content,
@@ -421,17 +421,17 @@ export class QwenAgentManager {
}
/**
* 创建新会话
* Create new session
*
* 注意认证应该在connect()方法中完成,这里只创建会话
* Note: Authentication should be done in connect() method, only create session here
*
* @param workingDir - 工作目录
* @returns 新创建的 session ID
* @param workingDir - Working directory
* @returns Newly created session ID
*/
async createNewSession(workingDir: string): Promise<string | null> {
console.log('[QwenAgentManager] Creating new session...');
// 先进行认证
// Authenticate first
console.log('[QwenAgentManager] Authenticating before creating session...');
try {
const config = vscode.workspace.getConfiguration('qwenCode');
@@ -455,16 +455,16 @@ export class QwenAgentManager {
}
/**
* 切换到指定会话
* Switch to specified session
*
* @param sessionId - 会话ID
* @param sessionId - Session ID
*/
async switchToSession(sessionId: string): Promise<void> {
await this.connection.switchSession(sessionId);
}
/**
* 取消当前提示
* Cancel current prompt
*/
async cancelCurrentPrompt(): Promise<void> {
console.log('[QwenAgentManager] Cancelling current prompt');
@@ -472,9 +472,9 @@ export class QwenAgentManager {
}
/**
* 注册消息回调
* Register message callback
*
* @param callback - 消息回调函数
* @param callback - Message callback function
*/
onMessage(callback: (message: ChatMessage) => void): void {
this.callbacks.onMessage = callback;
@@ -482,9 +482,9 @@ export class QwenAgentManager {
}
/**
* 注册流式文本块回调
* Register stream chunk callback
*
* @param callback - 流式文本块回调函数
* @param callback - Stream chunk callback function
*/
onStreamChunk(callback: (chunk: string) => void): void {
this.callbacks.onStreamChunk = callback;
@@ -492,9 +492,9 @@ export class QwenAgentManager {
}
/**
* 注册思考文本块回调
* Register thought chunk callback
*
* @param callback - 思考文本块回调函数
* @param callback - Thought chunk callback function
*/
onThoughtChunk(callback: (chunk: string) => void): void {
this.callbacks.onThoughtChunk = callback;
@@ -502,9 +502,9 @@ export class QwenAgentManager {
}
/**
* 注册工具调用回调
* Register tool call callback
*
* @param callback - 工具调用回调函数
* @param callback - Tool call callback function
*/
onToolCall(callback: (update: ToolCallUpdateData) => void): void {
this.callbacks.onToolCall = callback;
@@ -512,9 +512,9 @@ export class QwenAgentManager {
}
/**
* 注册计划回调
* Register plan callback
*
* @param callback - 计划回调函数
* @param callback - Plan callback function
*/
onPlan(callback: (entries: PlanEntry[]) => void): void {
this.callbacks.onPlan = callback;
@@ -522,9 +522,9 @@ export class QwenAgentManager {
}
/**
* 注册权限请求回调
* Register permission request callback
*
* @param callback - 权限请求回调函数
* @param callback - Permission request callback function
*/
onPermissionRequest(
callback: (request: AcpPermissionRequest) => Promise<string>,
@@ -534,21 +534,21 @@ export class QwenAgentManager {
}
/**
* 断开连接
* Disconnect
*/
disconnect(): void {
this.connection.disconnect();
}
/**
* 检查是否已连接
* Check if connected
*/
get isConnected(): boolean {
return this.connection.isConnected;
}
/**
* 获取当前会话ID
* Get current session ID
*/
get currentSessionId(): string | null {
return this.connection.currentSessionId;

View File

@@ -5,9 +5,9 @@
*/
/**
* Qwen连接处理器
* Qwen Connection Handler
*
* 负责Qwen Agent的连接建立、认证和会话创建
* Handles Qwen Agent connection establishment, authentication, and session creation
*/
import * as vscode from 'vscode';
@@ -16,17 +16,17 @@ import type { QwenSessionReader } from '../services/qwenSessionReader.js';
import type { AuthStateManager } from '../auth/authStateManager.js';
/**
* Qwen连接处理器类
* 处理连接、认证和会话初始化
* Qwen Connection Handler class
* Handles connection, authentication, and session initialization
*/
export class QwenConnectionHandler {
/**
* 连接到Qwen服务并建立会话
* Connect to Qwen service and establish session
*
* @param connection - ACP连接实例
* @param sessionReader - 会话读取器实例
* @param workingDir - 工作目录
* @param authStateManager - 认证状态管理器(可选)
* @param connection - ACP connection instance
* @param sessionReader - Session reader instance
* @param workingDir - Working directory
* @param authStateManager - Auth state manager (optional)
*/
async connect(
connection: AcpConnection,
@@ -47,7 +47,7 @@ export class QwenConnectionHandler {
const model = config.get<string>('qwen.model', '');
const proxy = config.get<string>('qwen.proxy', '');
// 构建额外的CLI参数
// Build extra CLI arguments
const extraArgs: string[] = [];
if (openaiApiKey) {
extraArgs.push('--openai-api-key', openaiApiKey);
@@ -65,10 +65,10 @@ export class QwenConnectionHandler {
await connection.connect('qwen', cliPath, workingDir, extraArgs);
// 确定认证方法
// Determine authentication method
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
// 检查是否有有效的缓存认证
// Check if we have valid cached authentication
if (authStateManager) {
const hasValidAuth = await authStateManager.hasValidAuth(
workingDir,
@@ -79,10 +79,10 @@ export class QwenConnectionHandler {
}
}
// 尝试恢复现有会话或创建新会话
// Try to restore existing session or create new session
let sessionRestored = false;
// 尝试从本地文件获取会话
// Try to get session from local files
console.log('[QwenAgentManager] Reading local session files...');
try {
const sessions = await sessionReader.getAllSessions(workingDir);
@@ -129,11 +129,11 @@ export class QwenConnectionHandler {
);
}
// 如果无法恢复会话则创建新会话
// Create new session if unable to restore
if (!sessionRestored) {
console.log('[QwenAgentManager] Creating new session...');
// 检查是否有有效的缓存认证
// Check if we have valid cached authentication
let hasValidAuth = false;
if (authStateManager) {
hasValidAuth = await authStateManager.hasValidAuth(
@@ -142,7 +142,7 @@ export class QwenConnectionHandler {
);
}
// 只在没有有效缓存认证时进行认证
// Only authenticate if we don't have valid cached auth
if (!hasValidAuth) {
console.log(
'[QwenAgentManager] Authenticating before creating session...',
@@ -151,7 +151,7 @@ export class QwenConnectionHandler {
await connection.authenticate(authMethod);
console.log('[QwenAgentManager] Authentication successful');
// 保存认证状态
// Save auth state
if (authStateManager) {
console.log(
'[QwenAgentManager] Saving auth state after successful authentication',
@@ -160,7 +160,7 @@ export class QwenConnectionHandler {
}
} catch (authError) {
console.error('[QwenAgentManager] Authentication failed:', authError);
// 清除可能无效的缓存
// Clear potentially invalid cache
if (authStateManager) {
console.log(
'[QwenAgentManager] Clearing auth cache due to authentication failure',
@@ -182,7 +182,7 @@ export class QwenConnectionHandler {
await this.newSessionWithRetry(connection, workingDir, 3);
console.log('[QwenAgentManager] New session created successfully');
// 确保认证状态已保存(防止重复认证)
// Ensure auth state is saved (prevent repeated authentication)
if (authStateManager && !hasValidAuth) {
console.log(
'[QwenAgentManager] Saving auth state after successful session creation',
@@ -193,7 +193,7 @@ export class QwenConnectionHandler {
console.log(`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`);
console.log(`[QwenAgentManager] Error details:`, sessionError);
// 清除缓存
// Clear cache
if (authStateManager) {
console.log('[QwenAgentManager] Clearing auth cache due to failure');
await authStateManager.clearAuthState();
@@ -209,11 +209,11 @@ export class QwenConnectionHandler {
}
/**
* 创建新会话(带重试)
* Create new session (with retry)
*
* @param connection - ACP连接实例
* @param workingDir - 工作目录
* @param maxRetries - 最大重试次数
* @param connection - ACP connection instance
* @param workingDir - Working directory
* @param maxRetries - Maximum number of retries
*/
private async newSessionWithRetry(
connection: AcpConnection,

View File

@@ -5,17 +5,17 @@
*/
/**
* Qwen会话更新处理器
* Qwen Session Update Handler
*
* 负责处理来自ACP的会话更新并分发到相应的回调函数
* Handles session updates from ACP and dispatches them to appropriate callbacks
*/
import type { AcpSessionUpdate } from '../shared/acpTypes.js';
import type { QwenAgentCallbacks } from './qwenTypes.js';
/**
* Qwen会话更新处理器类
* 处理各种会话更新事件并调用相应的回调
* Qwen Session Update Handler class
* Processes various session update events and calls appropriate callbacks
*/
export class QwenSessionUpdateHandler {
private callbacks: QwenAgentCallbacks;
@@ -25,18 +25,18 @@ export class QwenSessionUpdateHandler {
}
/**
* 更新回调函数
* Update callbacks
*
* @param callbacks - 新的回调函数集合
* @param callbacks - New callback collection
*/
updateCallbacks(callbacks: QwenAgentCallbacks): void {
this.callbacks = callbacks;
}
/**
* 处理会话更新
* Handle session update
*
* @param data - ACP会话更新数据
* @param data - ACP session update data
*/
handleSessionUpdate(data: AcpSessionUpdate): void {
const update = data.update;
@@ -47,21 +47,21 @@ export class QwenSessionUpdateHandler {
switch (update.sessionUpdate) {
case 'user_message_chunk':
// 处理用户消息块
// Handle user message chunk
if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text);
}
break;
case 'agent_message_chunk':
// 处理助手消息块
// Handle assistant message chunk
if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text);
}
break;
case 'agent_thought_chunk':
// 处理思考块 - 使用特殊回调
// Handle thought chunk - use special callback
console.log(
'[SessionUpdateHandler] 🧠 THOUGHT CHUNK:',
update.content?.text,
@@ -73,7 +73,7 @@ export class QwenSessionUpdateHandler {
);
this.callbacks.onThoughtChunk(update.content.text);
} else if (this.callbacks.onStreamChunk) {
// 回退到常规流处理
// Fallback to regular stream processing
console.log(
'[SessionUpdateHandler] 🧠 Falling back to onStreamChunk',
);
@@ -83,7 +83,7 @@ export class QwenSessionUpdateHandler {
break;
case 'tool_call': {
// 处理新的工具调用
// Handle new tool call
if (this.callbacks.onToolCall && 'toolCallId' in update) {
this.callbacks.onToolCall({
toolCallId: update.toolCallId as string,
@@ -103,7 +103,7 @@ export class QwenSessionUpdateHandler {
}
case 'tool_call_update': {
// 处理工具调用状态更新
// Handle tool call status update
if (this.callbacks.onToolCall && 'toolCallId' in update) {
this.callbacks.onToolCall({
toolCallId: update.toolCallId as string,
@@ -123,7 +123,7 @@ export class QwenSessionUpdateHandler {
}
case 'plan': {
// 处理计划更新
// Handle plan update
if ('entries' in update) {
const entries = update.entries as Array<{
content: string;
@@ -134,7 +134,7 @@ export class QwenSessionUpdateHandler {
if (this.callbacks.onPlan) {
this.callbacks.onPlan(entries);
} else if (this.callbacks.onStreamChunk) {
// 回退到流处理
// Fallback to stream processing
const planText =
'\n📋 Plan:\n' +
entries

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,13 @@
import type React from 'react';
import type { ContextAttachment } from '../ContextAttachmentManager.js';
import {
FileListIcon,
PlusSmallIcon,
SymbolIcon,
SelectionIcon,
CloseSmallIcon,
} from './icons/index.js';
import './ContextPills.css';
interface ContextPillsProps {
@@ -28,45 +35,13 @@ export const ContextPills: React.FC<ContextPillsProps> = ({
const getIcon = (type: string) => {
switch (type) {
case 'file':
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M5 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Z" />
</svg>
);
return <FileListIcon />;
case 'symbol':
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 1a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 7.293V1.5A.5.5 0 0 1 8 1Z" />
</svg>
);
return <SymbolIcon />;
case 'selection':
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Z" />
</svg>
);
return <SelectionIcon />;
default:
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 2a.5.5 0 0 1 .5.5V5h2.5a.5.5 0 0 1 0 1H8.5v2.5a.5.5 0 0 1-1 0V6H5a.5.5 0 0 1 0-1h2.5V2.5A.5.5 0 0 1 8 2Z" />
</svg>
);
return <PlusSmallIcon />;
}
};
@@ -81,13 +56,7 @@ export const ContextPills: React.FC<ContextPillsProps> = ({
onClick={() => onRemove(attachment.id)}
aria-label="Remove attachment"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708Z" />
</svg>
<CloseSmallIcon />
</button>
</div>
))}

View File

@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { TerminalIcon, CloseIcon } from './icons/index.js';
interface InfoBannerProps {
/**
@@ -56,22 +57,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
>
<div className="flex items-center flex-1 min-w-0" style={{ gap: '12px' }}>
{/* Icon */}
<svg
className="flex-shrink-0 w-4 h-4"
width="16"
height="16"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
style={{ fill: 'var(--app-primary-foreground)' }}
>
<path d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"></path>
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z"></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
></path>
</svg>
<TerminalIcon className="flex-shrink-0 w-4 h-4" />
{/* Message */}
<label
@@ -116,21 +102,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
aria-label="Close banner"
onClick={onDismiss}
>
<svg
className="w-[10px] h-[10px]"
width="10"
height="10"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L13 13M1 13L13 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
></path>
</svg>
<CloseIcon className="w-[10px] h-[10px]" />
</button>
</div>
);

View File

@@ -5,6 +5,16 @@
*/
import type React from 'react';
import {
EditPencilIcon,
AutoEditIcon,
PlanModeIcon,
CodeBracketsIcon,
ThinkingIcon,
SlashCommandIcon,
LinkIcon,
ArrowUpIcon,
} from './icons/index.js';
type EditMode = 'ask' | 'auto' | 'plan';
@@ -39,50 +49,19 @@ const getEditModeInfo = (editMode: EditMode) => {
return {
text: 'Ask before edits',
title: 'Qwen will ask before each edit. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
clipRule="evenodd"
></path>
</svg>
),
icon: <EditPencilIcon />,
};
case 'auto':
return {
text: 'Edit automatically',
title: 'Qwen will edit files automatically. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path d="M2.53 3.956A1 1 0 0 0 1 4.804v6.392a1 1 0 0 0 1.53.848l5.113-3.196c.16-.1.279-.233.357-.383v2.73a1 1 0 0 0 1.53.849l5.113-3.196a1 1 0 0 0 0-1.696L9.53 3.956A1 1 0 0 0 8 4.804v2.731a.992.992 0 0 0-.357-.383L2.53 3.956Z"></path>
</svg>
),
icon: <AutoEditIcon />,
};
case 'plan':
return {
text: 'Plan mode',
title: 'Qwen will plan before executing. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path d="M4.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1ZM10.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1Z"></path>
</svg>
),
icon: <PlanModeIcon />,
};
default:
return {
@@ -208,19 +187,7 @@ export const InputForm: React.FC<InputFormProps> = ({
title={`Showing Qwen Code your current file selection: ${activeFileName}${activeSelection ? `#${activeSelection.startLine}-${activeSelection.endLine}` : ''}`}
onClick={onFocusActiveEditor}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
>
<path
fillRule="evenodd"
d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06ZM11.377 2.011a.75.75 0 0 1 .612.867l-2.5 14.5a.75.75 0 0 1-1.478-.255l2.5-14.5a.75.75 0 0 1 .866-.612Z"
clipRule="evenodd"
></path>
</svg>
<CodeBracketsIcon />
<span>
{activeFileName}
{activeSelection &&
@@ -259,26 +226,7 @@ export const InputForm: React.FC<InputFormProps> = ({
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={onToggleThinking}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
strokeWidth="0.27"
style={{
stroke: thinkingEnabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
fill: thinkingEnabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
}}
></path>
</svg>
<ThinkingIcon enabled={thinkingEnabled} />
</button>
{/* Command button */}
@@ -289,18 +237,7 @@ export const InputForm: React.FC<InputFormProps> = ({
title="Show command menu (/)"
onClick={onShowCommandMenu}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
clipRule="evenodd"
></path>
</svg>
<SlashCommandIcon />
</button>
{/* Attach button */}
@@ -311,18 +248,7 @@ export const InputForm: React.FC<InputFormProps> = ({
title="Attach context (Cmd/Ctrl + /)"
onClick={onAttachContext}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
clipRule="evenodd"
></path>
</svg>
<LinkIcon />
</button>
{/* Send button */}
@@ -335,18 +261,7 @@ export const InputForm: React.FC<InputFormProps> = ({
}}
disabled={isStreaming || !inputText.trim()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clipRule="evenodd"
></path>
</svg>
<ArrowUpIcon />
</button>
</div>
</form>

View File

@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { WarningTriangleIcon, CloseIcon } from './icons/index.js';
interface NotLoggedInMessageProps {
/**
@@ -28,99 +29,76 @@ export const NotLoggedInMessage: React.FC<NotLoggedInMessageProps> = ({
onLoginClick,
onDismiss,
}) => (
<div
className="flex items-start gap-3 p-4 my-4 rounded-lg"
style={{
backgroundColor: 'var(--app-warning-background)',
borderLeft: '3px solid var(--app-warning-border)',
}}
>
{/* Warning Icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="flex-shrink-0 w-5 h-5 mt-0.5"
style={{ color: 'var(--app-warning-foreground)' }}
<div
className="flex items-start gap-3 p-4 my-4 rounded-lg"
style={{
backgroundColor: 'var(--app-warning-background)',
borderLeft: '3px solid var(--app-warning-border)',
}}
>
{/* Warning Icon */}
<WarningTriangleIcon
className="flex-shrink-0 w-5 h-5 mt-0.5"
style={{ color: 'var(--app-warning-foreground)' }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className="m-0 mb-3 text-sm leading-relaxed"
style={{ color: 'var(--app-primary-foreground)' }}
>
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clipRule="evenodd"
/>
</svg>
{message}
</p>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className="m-0 mb-3 text-sm leading-relaxed"
style={{ color: 'var(--app-primary-foreground)' }}
>
{message}
</p>
{/* Login Button */}
<button
className="px-4 py-2 text-sm font-medium rounded transition-colors duration-200"
style={{
backgroundColor: 'var(--app-qwen-orange)',
color: 'white',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1';
}}
onClick={() => {
if (onDismiss) {
onDismiss();
}
onLoginClick();
}}
>
Login Now
</button>
</div>
{/* Optional Close Button */}
{onDismiss && (
<button
className="flex-shrink-0 flex items-center justify-center cursor-pointer rounded"
style={{
background: 'none',
border: 'none',
padding: '6px',
color: 'var(--app-secondary-foreground)',
borderRadius: '4px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'var(--app-ghost-button-hover-background)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label="Dismiss"
onClick={onDismiss}
>
<svg
className="w-[10px] h-[10px]"
width="10"
height="10"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L13 13M1 13L13 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
{/* Login Button */}
<button
className="px-4 py-2 text-sm font-medium rounded transition-colors duration-200"
style={{
backgroundColor: 'var(--app-qwen-orange)',
color: 'white',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1';
}}
onClick={() => {
if (onDismiss) {
onDismiss();
}
onLoginClick();
}}
>
Login Now
</button>
</div>
);
{/* Optional Close Button */}
{onDismiss && (
<button
className="flex-shrink-0 flex items-center justify-center cursor-pointer rounded"
style={{
background: 'none',
border: 'none',
padding: '6px',
color: 'var(--app-secondary-foreground)',
borderRadius: '4px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'var(--app-ghost-button-hover-background)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label="Dismiss"
onClick={onDismiss}
>
<CloseIcon className="w-[10px] h-[10px]" />
</button>
)}
</div>
);

View File

@@ -5,6 +5,11 @@
*/
import type React from 'react';
import {
PlanCompletedIcon,
PlanInProgressIcon,
PlanPendingIcon,
} from './icons/index.js';
import './PlanDisplay.css';
export interface PlanEntry {
@@ -28,63 +33,12 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="plan-icon completed"
>
<circle cx="7" cy="7" r="6" fill="currentColor" opacity="0.2" />
<path
d="M4 7.5L6 9.5L10 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
return <PlanCompletedIcon className="plan-icon completed" />;
case 'in_progress':
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="plan-icon in-progress"
>
<circle
cx="7"
cy="7"
r="5"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
/>
</svg>
);
return <PlanInProgressIcon className="plan-icon in-progress" />;
default:
// pending
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="plan-icon pending"
>
<circle
cx="7"
cy="7"
r="5.5"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
);
return <PlanPendingIcon className="plan-icon pending" />;
}
};
@@ -92,38 +46,8 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
<div className="plan-display">
<div className="plan-header">
<div className="plan-progress-icons">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="plan-progress-icon"
>
<circle
cx="7"
cy="7"
r="5.5"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="plan-progress-icon"
>
<circle cx="7" cy="7" r="6" fill="currentColor" opacity="0.2" />
<path
d="M4 7.5L6 9.5L10 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<PlanPendingIcon className="plan-progress-icon" />
<PlanCompletedIcon className="plan-progress-icon" />
</div>
<span className="plan-title">
{completedCount} of {totalCount} Done

View File

@@ -6,6 +6,7 @@
import type React from 'react';
import { useState, useEffect, useRef } from 'react';
import { CloseIcon } from './icons/index.js';
interface SaveSessionDialogProps {
isOpen: boolean;
@@ -70,14 +71,7 @@ export const SaveSessionDialog: React.FC<SaveSessionDialogProps> = ({
<div className="dialog-header">
<h3>Save Conversation</h3>
<button className="dialog-close" onClick={onClose} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
<CloseIcon width="16" height="16" />
</button>
</div>

View File

@@ -6,6 +6,13 @@
import React, { useState, useEffect } from 'react';
import { useVSCode } from '../hooks/useVSCode.js';
import {
RefreshIcon,
SaveDocumentIcon,
SearchIcon,
PlayIcon,
SwitchIcon,
} from './icons/index.js';
interface Session {
id: string;
@@ -86,63 +93,19 @@ export const SessionManager: React.FC<SessionManagerProps> = ({
disabled={isLoading}
title="Refresh sessions"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M13.3333 8C13.3333 10.9455 10.9455 13.3333 8 13.3333C5.05451 13.3333 2.66663 10.9455 2.66663 8C2.66663 5.05451 5.05451 2.66663 8 2.66663"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M10.6666 8L13.3333 8M13.3333 8L13.3333 5.33333M13.3333 8L10.6666 10.6667"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<RefreshIcon width="16" height="16" />
</button>
</div>
<div className="session-manager-actions">
<button className="secondary-button" onClick={handleSaveCurrent}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M2.66663 2.66663H10.6666L13.3333 5.33329V13.3333H2.66663V2.66663Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<SaveDocumentIcon width="16" height="16" />
Save Current
</button>
</div>
<div className="session-search">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M7.33329 12.6666C10.2788 12.6666 12.6666 10.2788 12.6666 7.33329C12.6666 4.38777 10.2788 2 7.33329 2C4.38777 2 2 4.38777 2 7.33329C2 10.2788 4.38777 12.6666 7.33329 12.6666Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.9999 14L11.0999 11.1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<SearchIcon width="16" height="16" />
<input
type="text"
placeholder="Search conversations..."
@@ -187,36 +150,14 @@ export const SessionManager: React.FC<SessionManagerProps> = ({
onClick={() => handleResumeSession(session.id)}
title="Resume this conversation"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.33337 4L10.6667 8L5.33337 12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<PlayIcon width="16" height="16" />
</button>
<button
className="icon-button"
onClick={() => handleSwitchSession(session.id)}
title="Switch to this conversation"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M10.6666 4L13.3333 6.66667L10.6666 9.33333"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.66663 6.66667H13.3333"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
<SwitchIcon width="16" height="16" />
</button>
</div>
</div>

View File

@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Playback and session control icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Play/resume icon (16x16)
* Used for resume session
*/
export const PlayIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M5.33337 4L10.6667 8L5.33337 12" />
</svg>
);
/**
* Switch/arrow right icon (16x16)
* Used for switch session
*/
export const SwitchIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M10.6666 4L13.3333 6.66667L10.6666 9.33333" />
<path d="M2.66663 6.66667H13.3333" />
</svg>
);

View File

@@ -0,0 +1,187 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Edit mode related icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Edit pencil icon (16x16)
* Used for "Ask before edits" mode
*/
export const EditPencilIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Auto/fast-forward icon (16x16)
* Used for "Edit automatically" mode
*/
export const AutoEditIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M2.53 3.956A1 1 0 0 0 1 4.804v6.392a1 1 0 0 0 1.53.848l5.113-3.196c.16-.1.279-.233.357-.383v2.73a1 1 0 0 0 1.53.849l5.113-3.196a1 1 0 0 0 0-1.696L9.53 3.956A1 1 0 0 0 8 4.804v2.731a.992.992 0 0 0-.357-.383L2.53 3.956Z" />
</svg>
);
/**
* Plan mode/bars icon (16x16)
* Used for "Plan mode"
*/
export const PlanModeIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M4.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1ZM10.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1Z" />
</svg>
);
/**
* Code brackets icon (20x20)
* Used for active file indicator
*/
export const CodeBracketsIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06ZM11.377 2.011a.75.75 0 0 1 .612.867l-2.5 14.5a.75.75 0 0 1-1.478-.255l2.5-14.5a.75.75 0 0 1 .866-.612Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Slash command icon (20x20)
* Used for command menu button
*/
export const SlashCommandIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Link/attachment icon (20x20)
* Used for attach context button
*/
export const LinkIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Open diff icon (16x16)
* Used for opening diff in VS Code
*/
export const OpenDiffIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M13.5 7l-4-4v3h-6v2h6v3l4-4z" />
</svg>
);

View File

@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* File and document related icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* File document icon (16x16)
* Used for file completion menu
*/
export const FileIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M9 2H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7l-5-5zm3 7V3.5L10.5 2H10v3a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V2H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1zM6 3h3v2H6V3z" />
</svg>
);
/**
* File list icon (16x16)
* Used for file type indicator in context pills
*/
export const FileListIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M5 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Z" />
</svg>
);
/**
* Save document icon (16x16)
* Used for save session button
*/
export const SaveDocumentIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M2.66663 2.66663H10.6666L13.3333 5.33329V13.3333H2.66663V2.66663Z" />
<path d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329" />
</svg>
);

View File

@@ -0,0 +1,216 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Navigation and action icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Chevron down icon (20x20)
* Used for dropdown arrows
*/
export const ChevronDownIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Plus icon (20x20)
* Used for new session button
*/
export const PlusIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
</svg>
);
/**
* Small plus icon (16x16)
* Used for default attachment type
*/
export const PlusSmallIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M8 2a.5.5 0 0 1 .5.5V5h2.5a.5.5 0 0 1 0 1H8.5v2.5a.5.5 0 0 1-1 0V6H5a.5.5 0 0 1 0-1h2.5V2.5A.5.5 0 0 1 8 2Z" />
</svg>
);
/**
* Arrow up icon (20x20)
* Used for send message button
*/
export const ArrowUpIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Close X icon (14x14)
* Used for close buttons in banners and dialogs
*/
export const CloseIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
d="M1 1L13 13M1 13L13 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
/**
* Close X icon for context pills (16x16)
* Used to remove attachments
*/
export const CloseSmallIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708Z" />
</svg>
);
/**
* Search/magnifying glass icon (20x20)
* Used for search input
*/
export const SearchIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Refresh/reload icon (16x16)
* Used for refresh session list
*/
export const RefreshIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M13.3333 8C13.3333 10.9455 10.9455 13.3333 8 13.3333C5.05451 13.3333 2.66663 10.9455 2.66663 8C2.66663 5.05451 5.05451 2.66663 8 2.66663" />
<path d="M10.6666 8L13.3333 8M13.3333 8L13.3333 5.33333M13.3333 8L10.6666 10.6667" />
</svg>
);

View File

@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Special UI icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
interface ThinkingIconProps extends IconProps {
/**
* Whether thinking is enabled (affects styling)
*/
enabled?: boolean;
}
/**
* Thinking/brain wave icon (16x16)
* Used for thinking mode toggle
*/
export const ThinkingIcon: React.FC<ThinkingIconProps> = ({
size = 16,
className,
enabled = false,
style,
...props
}) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
{...props}
>
<path
d="M10.3927 6.81824C10.3927 7.14824 10.1767 7.42424 9.91668 7.42424H9.75968L9.30068 5.39324C9.47868 5.28524 9.71068 5.22424 9.91668 5.22424C10.1607 5.22424 10.3927 5.50824 10.3927 5.83624V6.81824ZM4.90268 5.19124C4.55468 5.14224 4.22568 5.42624 4.22568 5.83624V6.81824C4.22568 7.17324 4.51068 7.42424 4.80068 7.42424H4.90268V5.19124ZM4.90268 8.62224C4.90268 8.70424 4.89068 8.77724 4.86768 8.84524L4.72268 9.34024C4.64868 9.58424 4.45768 9.77224 4.22568 9.84624V8.62224C4.22568 8.25824 4.51068 8.01824 4.80068 8.01824H4.90268V8.62224ZM4.78068 10.4622C4.71468 10.6532 4.60968 10.8202 4.45768 10.9572L4.05568 11.3252C3.63768 11.7132 3.00268 11.6392 2.66968 11.1692C2.35768 10.7292 2.45668 10.1212 2.89268 9.79624L3.16868 9.59424C3.36768 9.44724 3.64068 9.51324 3.76268 9.74424L4.14268 10.4622H4.78068ZM5.50268 8.62224C5.50268 8.89724 5.70768 9.08624 5.96068 9.10124H6.15568V10.9802C6.15568 11.3402 5.88268 11.6132 5.56068 11.6132H5.02068C4.68868 11.6132 4.42668 11.3302 4.42668 10.9802V9.85824C4.42668 9.72024 4.55568 9.65324 4.68568 9.70524C4.93568 9.80324 5.17568 9.77924 5.36468 9.72324C5.43568 9.70224 5.50268 9.63324 5.50268 9.54824V8.62224ZM5.50268 7.42424H5.80868H6.07968V8.01824H5.80868C5.62068 8.01824 5.50268 8.25824 5.50268 8.62224V7.42424ZM6.72668 4.93024C6.60368 4.93024 6.49468 4.98624 6.42568 5.08124L6.07968 5.55924V4.79224C6.07968 4.60024 6.14768 4.41824 6.25568 4.26724C6.36368 4.11624 6.53468 4.00724 6.72668 4.00724H8.19668C8.41568 4.00724 8.60068 4.14024 8.69768 4.32424L9.08968 5.07524H9.91668C10.5367 5.07524 10.9927 5.52224 10.9927 6.08724V6.56924C10.9927 7.13824 10.5367 7.57724 9.91668 7.57724H9.60568L9.88968 8.80224C9.95068 9.06924 9.78268 9.33324 9.50668 9.37124C9.46268 9.37724 9.41668 9.37724 9.37168 9.37124L9.23368 9.34524L8.72668 11.2652C8.61668 11.6692 8.24568 11.9232 7.80868 11.9232C7.59668 11.9232 7.39468 11.8462 7.24068 11.7082L7.14068 11.6132H7.69168V10.0562H7.07168C6.79468 10.0562 6.56968 9.81524 6.56968 9.51424V9.10024L6.78468 8.77724C6.84768 8.68324 6.87968 8.57824 6.87968 8.46824C6.87968 8.24324 6.75268 8.01824 6.55768 8.01824H6.42668V5.89024L7.27568 5.18624C7.47468 5.02524 7.56068 4.98924 7.56068 4.93024C7.56068 4.86724 7.40568 4.78724 7.20668 4.85024L6.72668 5.00524V4.93024ZM8.38268 8.01824H8.73068L9.01568 9.24624L8.30868 9.09724L8.38268 8.72824V8.01824ZM8.11668 10.0552H8.00968V11.1032L8.26668 10.1322L8.11668 10.0552ZM9.07368 10.2272L8.52468 11.7662C8.47068 11.9092 8.34668 12.0082 8.20168 12.0362C8.14268 12.0482 8.08168 12.0482 8.02068 12.0362C7.86568 12.0062 7.73068 11.9042 7.66968 11.7552C7.61268 11.6142 7.58268 11.5622 7.55268 11.4912L7.41168 11.1662L7.36468 10.7362C7.35668 10.6562 7.40068 10.5822 7.47368 10.5482C7.56368 10.5082 7.61568 10.4402 7.64068 10.3502L7.69168 10.0562H8.00968L8.68268 10.4422L9.07368 10.2272ZM7.69168 7.27024V5.86524L7.62968 5.91624C7.43768 6.07324 7.15468 6.08424 6.95168 5.94724L6.87968 5.89724V7.42424H8.21368L8.00968 6.53424C7.96768 6.35324 8.06568 6.17024 8.22568 6.10524C8.27368 6.08724 8.32168 6.08524 8.37068 6.09524L9.02868 6.22224C9.15768 6.24724 9.22668 6.39524 9.17268 6.52424L9.09668 6.71024C9.08368 6.74324 9.06368 6.77124 9.04068 6.79624L8.21268 7.57724L7.95468 7.27024H7.69168ZM8.00968 9.26224C7.94168 9.30024 7.88368 9.34024 7.83068 9.38624C7.74368 9.46024 7.66768 9.54724 7.60968 9.65224L7.54668 9.76324C7.50868 9.83224 7.48468 9.90924 7.47468 9.99124L7.47268 10.0032V10.0562H6.72668V9.51124C6.72668 9.41924 6.77068 9.33424 6.84668 9.28024L7.05768 9.13024C7.12568 9.08224 7.16268 9.00424 7.16268 8.92024V8.01824H7.83068V8.77724H8.07368V8.81624C8.07368 8.94124 8.05168 9.06024 8.00968 9.17324V9.26224ZM6.72668 8.57324C6.72668 8.57024 6.72668 8.56524 6.72668 8.56224V8.01824H6.87968V8.46824C6.87968 8.50624 6.87568 8.54324 6.86868 8.57924L6.72668 8.57324Z"
strokeWidth="0.27"
style={{
stroke: enabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
fill: enabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
...style,
}}
/>
</svg>
);
/**
* Terminal/code editor icon (20x20)
* Used for terminal preference info banner
*/
export const TerminalIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"
clipRule="evenodd"
/>
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z" />
<path
fillRule="evenodd"
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
clipRule="evenodd"
/>
</svg>
);

View File

@@ -0,0 +1,196 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Status and state related icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Plan completed icon (14x14)
* Used for completed plan items
*/
export const PlanCompletedIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<circle cx="7" cy="7" r="6" fill="currentColor" opacity="0.2" />
<path
d="M4 7.5L6 9.5L10 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
/**
* Plan in progress icon (14x14)
* Used for in-progress plan items
*/
export const PlanInProgressIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<circle
cx="7"
cy="7"
r="5"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
/>
</svg>
);
/**
* Plan pending icon (14x14)
* Used for pending plan items
*/
export const PlanPendingIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<circle
cx="7"
cy="7"
r="5.5"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
);
/**
* Warning triangle icon (20x20)
* Used for warning messages
*/
export const WarningTriangleIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clipRule="evenodd"
/>
</svg>
);
/**
* User profile icon (16x16)
* Used for login command
*/
export const UserIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
</svg>
);
/**
* Symbol arrow icon (16x16)
* Used for symbol type in context pills
*/
export const SymbolIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M8 1a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 7.293V1.5A.5.5 0 0 1 8 1Z" />
</svg>
);
/**
* Selection/text lines icon (16x16)
* Used for selection type in context pills
*/
export const SelectionIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Z" />
</svg>
);

View File

@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Icons index - exports all icon components
*/
// Types
export type { IconProps } from './types.js';
// File icons
export { FileIcon, FileListIcon, SaveDocumentIcon } from './FileIcons.js';
// Navigation icons
export {
ChevronDownIcon,
PlusIcon,
PlusSmallIcon,
ArrowUpIcon,
CloseIcon,
CloseSmallIcon,
SearchIcon,
RefreshIcon,
} from './NavigationIcons.js';
// Edit mode icons
export {
EditPencilIcon,
AutoEditIcon,
PlanModeIcon,
CodeBracketsIcon,
SlashCommandIcon,
LinkIcon,
OpenDiffIcon,
} from './EditIcons.js';
// Status icons
export {
PlanCompletedIcon,
PlanInProgressIcon,
PlanPendingIcon,
WarningTriangleIcon,
UserIcon,
SymbolIcon,
SelectionIcon,
} from './StatusIcons.js';
// Action icons
export { PlayIcon, SwitchIcon } from './ActionIcons.js';
// Special icons
export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js';

View File

@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Common icon props interface
*/
import type React from 'react';
export interface IconProps extends React.SVGProps<SVGSVGElement> {
/**
* Icon size (width and height)
* @default 16
*/
size?: number;
/**
* Additional CSS classes
*/
className?: string;
}

View File

@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { ChevronDownIcon, PlusIcon } from '../icons/index.js';
interface ChatHeaderProps {
currentSessionTitle: string;
@@ -41,19 +42,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
<span style={{ fontSize: 'var(--vscode-chat-font-size, 13px)' }}>
{currentSessionTitle}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="w-3.5 h-3.5"
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
></path>
</svg>
<ChevronDownIcon className="w-3.5 h-3.5" />
</span>
</button>
@@ -94,16 +83,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onClick={onNewSession}
title="New Session"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
className="w-4 h-4"
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"></path>
</svg>
<PlusIcon className="w-4 h-4" />
</button>
</div>
);

View File

@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { groupSessionsByDate } from '../../utils/sessionGrouping.js';
import { getTimeAgo } from '../../utils/timeUtils.js';
import { SearchIcon } from '../icons/index.js';
interface SessionSelectorProps {
visible: boolean;
sessions: Array<Record<string, unknown>>;
currentSessionId: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelectSession: (sessionId: string) => void;
onClose: () => void;
}
/**
* 会话选择器组件
* 显示会话列表并支持搜索和选择
*/
export const SessionSelector: React.FC<SessionSelectorProps> = ({
visible,
sessions,
currentSessionId,
searchQuery,
onSearchChange,
onSelectSession,
onClose,
}) => {
if (!visible) {
return null;
}
const hasNoSessions = sessions.length === 0;
return (
<>
<div className="session-selector-backdrop" onClick={onClose} />
<div
className="session-dropdown"
tabIndex={-1}
style={{
top: '34px',
left: '10px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Search Box */}
<div className="session-search">
<SearchIcon className="session-search-icon" />
<input
type="text"
className="session-search-input"
placeholder="Search sessions…"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* Session List with Grouping */}
<div className="session-list-content">
{hasNoSessions ? (
<div
style={{
padding: '20px',
textAlign: 'center',
color: 'var(--app-secondary-foreground)',
}}
>
{searchQuery ? 'No matching sessions' : 'No sessions available'}
</div>
) : (
groupSessionsByDate(sessions).map((group) => (
<React.Fragment key={group.label}>
<div className="session-group-label">{group.label}</div>
<div className="session-group">
{group.sessions.map((session) => {
const sessionId =
(session.id as string) ||
(session.sessionId as string) ||
'';
const title =
(session.title as string) ||
(session.name as string) ||
'Untitled';
const lastUpdated =
(session.lastUpdated as string) ||
(session.startTime as string) ||
'';
const isActive = sessionId === currentSessionId;
return (
<button
key={sessionId}
className={`session-item ${isActive ? 'active' : ''}`}
onClick={() => {
onSelectSession(sessionId);
onClose();
}}
>
<span className="session-item-title">{title}</span>
<span className="session-item-time">
{getTimeAgo(lastUpdated)}
</span>
</button>
);
})}
</div>
</React.Fragment>
))
)}
</div>
</div>
</>
);
};

View File

@@ -13,6 +13,7 @@ import {
calculateDiffStats,
formatDiffStatsDetailed,
} from '../../../utils/diffStats.js';
import { OpenDiffIcon } from '../../icons/index.js';
import './DiffDisplay.css';
/**
@@ -119,9 +120,7 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
onClick={onOpenDiff}
title="Open in VS Code diff viewer"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M13.5 7l-4-4v3h-6v2h6v3l4-4z" fill="currentColor" />
</svg>
<OpenDiffIcon width="14" height="14" />
Open Diff
</button>
)}

View File

@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { ChevronDownIcon, PlusIcon } from '../icons/index.js';
interface ChatHeaderProps {
currentSessionTitle: string;
@@ -41,19 +42,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
<span style={{ fontSize: 'var(--vscode-chat-font-size, 13px)' }}>
{currentSessionTitle}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="w-3.5 h-3.5"
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
></path>
</svg>
<ChevronDownIcon className="w-3.5 h-3.5" />
</span>
</button>
@@ -94,16 +83,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onClick={onNewSession}
title="New Session"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
className="w-4 h-4"
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"></path>
</svg>
<PlusIcon className="w-4 h-4" />
</button>
</div>
);

View File

@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Loading messages from Claude Code CLI
* Source: packages/cli/src/ui/hooks/usePhraseCycler.ts
*/
export const WITTY_LOADING_PHRASES = [
"I'm Feeling Lucky",
'Shipping awesomeness... ',
'Painting the serifs back on...',
'Navigating the slime mold...',
'Consulting the digital spirits...',
'Reticulating splines...',
'Warming up the AI hamsters...',
'Asking the magic conch shell...',
'Generating witty retort...',
'Polishing the algorithms...',
"Don't rush perfection (or my code)...",
'Brewing fresh bytes...',
'Counting electrons...',
'Engaging cognitive processors...',
'Checking for syntax errors in the universe...',
'One moment, optimizing humor...',
'Shuffling punchlines...',
'Untangling neural nets...',
'Compiling brilliance...',
'Loading wit.exe...',
'Summoning the cloud of wisdom...',
'Preparing a witty response...',
"Just a sec, I'm debugging reality...",
'Confuzzling the options...',
'Tuning the cosmic frequencies...',
'Crafting a response worthy of your patience...',
'Compiling the 1s and 0s...',
'Resolving dependencies... and existential crises...',
'Defragmenting memories... both RAM and personal...',
'Rebooting the humor module...',
'Caching the essentials (mostly cat memes)...',
'Optimizing for ludicrous speed',
"Swapping bits... don't tell the bytes...",
'Garbage collecting... be right back...',
'Assembling the interwebs...',
'Converting coffee into code...',
'Updating the syntax for reality...',
'Rewiring the synapses...',
'Looking for a misplaced semicolon...',
"Greasin' the cogs of the machine...",
'Pre-heating the servers...',
'Calibrating the flux capacitor...',
'Engaging the improbability drive...',
'Channeling the Force...',
'Aligning the stars for optimal response...',
'So say we all...',
'Loading the next great idea...',
"Just a moment, I'm in the zone...",
'Preparing to dazzle you with brilliance...',
"Just a tick, I'm polishing my wit...",
"Hold tight, I'm crafting a masterpiece...",
"Just a jiffy, I'm debugging the universe...",
"Just a moment, I'm aligning the pixels...",
"Just a sec, I'm optimizing the humor...",
"Just a moment, I'm tuning the algorithms...",
'Warp speed engaged...',
'Mining for more Dilithium crystals...',
"Don't panic...",
'Following the white rabbit...',
'The truth is in here... somewhere...',
'Blowing on the cartridge...',
'Loading... Do a barrel roll!',
'Waiting for the respawn...',
'Finishing the Kessel Run in less than 12 parsecs...',
"The cake is not a lie, it's just still loading...",
'Fiddling with the character creation screen...',
"Just a moment, I'm finding the right meme...",
"Pressing 'A' to continue...",
'Herding digital cats...',
'Polishing the pixels...',
'Finding a suitable loading screen pun...',
'Distracting you with this witty phrase...',
'Almost there... probably...',
'Our hamsters are working as fast as they can...',
'Giving Cloudy a pat on the head...',
'Petting the cat...',
'Rickrolling my boss...',
'Never gonna give you up, never gonna let you down...',
'Slapping the bass...',
'Tasting the snozberries...',
"I'm going the distance, I'm going for speed...",
'Is this the real life? Is this just fantasy?...',
"I've got a good feeling about this...",
'Poking the bear...',
'Doing research on the latest memes...',
'Figuring out how to make this more witty...',
'Hmmm... let me think...',
'What do you call a fish with no eyes? A fsh...',
'Why did the computer go to therapy? It had too many bytes...',
"Why don't programmers like nature? It has too many bugs...",
'Why do programmers prefer dark mode? Because light attracts bugs...',
'Why did the developer go broke? Because they used up all their cache...',
"What can you do with a broken pencil? Nothing, it's pointless...",
'Applying percussive maintenance...',
'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...',
'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...",
'Engage.',
"I'll be back... with an answer.",
'My other process is a TARDIS...',
'Communing with the machine spirit...',
'Letting the thoughts marinate...',
'Just remembered where I put my keys...',
'Pondering the orb...',
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
'Initiating thoughtful gaze...',
"What's a computer's favorite snack? Microchips.",
"Why do Java developers wear glasses? Because they don't C#.",
'Charging the laser... pew pew!',
'Dividing by zero... just kidding!',
'Looking for an adult superviso... I mean, processing.',
'Making it go beep boop.',
'Buffering... because even AIs need a moment.',
'Entangling quantum particles for a faster response...',
'Polishing the chrome... on the algorithms.',
'Are you not entertained? (Working on it!)',
'Summoning the code gremlins... to help, of course.',
'Just waiting for the dial-up tone to finish...',
'Recalibrating the humor-o-meter.',
'My other loading screen is even funnier.',
"Pretty sure there's a cat walking on the keyboard somewhere...",
'Enhancing... Enhancing... Still loading.',
"It's not a bug, it's a feature... of this loading screen.",
'Have you tried turning it off and on again? (The loading screen, not me.)',
'Constructing additional pylons...',
"New line? That's Ctrl+J.",
];
/**
* Get random loading message
*/
export const getRandomLoadingMessage = (): string =>
WITTY_LOADING_PHRASES[
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
];

View File

@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
/**
* Auth message handler
* Handles all authentication-related messages
*/
export class AuthMessageHandler extends BaseMessageHandler {
private loginHandler: (() => Promise<void>) | null = null;
canHandle(messageType: string): boolean {
return ['login'].includes(messageType);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
switch (message.type) {
case 'login':
await this.handleLogin();
break;
default:
console.warn(
'[AuthMessageHandler] Unknown message type:',
message.type,
);
break;
}
}
/**
* Set login handler
*/
setLoginHandler(handler: () => Promise<void>): void {
this.loginHandler = handler;
}
/**
* Handle login request
*/
private async handleLogin(): Promise<void> {
try {
console.log('[AuthMessageHandler] Login requested');
if (this.loginHandler) {
await this.loginHandler();
} else {
vscode.window.showInformationMessage(
'Please wait while we connect to Qwen Code...',
);
// Fallback: trigger WebViewProvider's forceReLogin
await vscode.commands.executeCommand('qwenCode.login');
}
} catch (error) {
console.error('[AuthMessageHandler] Login failed:', error);
this.sendToWebView({
type: 'loginError',
data: { message: `Login failed: ${error}` },
});
}
}
}

View File

@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { QwenAgentManager } from '../agents/qwenAgentManager.js';
import type { ConversationStore } from '../storage/conversationStore.js';
/**
* Base message handler interface
* All sub-handlers should implement this interface
*/
export interface IMessageHandler {
/**
* Handle message
* @param message - Message object
* @returns Promise<void>
*/
handle(message: { type: string; data?: unknown }): Promise<void>;
/**
* Check if this handler can handle the message type
* @param messageType - Message type
* @returns boolean
*/
canHandle(messageType: string): boolean;
}
/**
* Base message handler class
* Provides common dependency injection and helper methods
*/
export abstract class BaseMessageHandler implements IMessageHandler {
constructor(
protected agentManager: QwenAgentManager,
protected conversationStore: ConversationStore,
protected currentConversationId: string | null,
protected sendToWebView: (message: unknown) => void,
) {}
abstract handle(message: { type: string; data?: unknown }): Promise<void>;
abstract canHandle(messageType: string): boolean;
/**
* Update current conversation ID
*/
setCurrentConversationId(id: string | null): void {
this.currentConversationId = id;
}
/**
* Get current conversation ID
*/
getCurrentConversationId(): string | null {
return this.currentConversationId;
}
}

View File

@@ -0,0 +1,111 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import { getFileName } from '../../utils/webviewUtils.js';
/**
* Editor message handler
* Handles all editor state-related messages
*/
export class EditorMessageHandler extends BaseMessageHandler {
canHandle(messageType: string): boolean {
return ['getActiveEditor', 'focusActiveEditor'].includes(messageType);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
switch (message.type) {
case 'getActiveEditor':
await this.handleGetActiveEditor();
break;
case 'focusActiveEditor':
await this.handleFocusActiveEditor();
break;
default:
console.warn(
'[EditorMessageHandler] Unknown message type:',
message.type,
);
break;
}
}
/**
* Get current active editor info
*/
private async handleGetActiveEditor(): Promise<void> {
try {
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor) {
const filePath = activeEditor.document.uri.fsPath;
const fileName = getFileName(filePath);
let selectionInfo = null;
if (!activeEditor.selection.isEmpty) {
const selection = activeEditor.selection;
selectionInfo = {
startLine: selection.start.line + 1,
endLine: selection.end.line + 1,
};
}
this.sendToWebView({
type: 'activeEditorChanged',
data: { fileName, filePath, selection: selectionInfo },
});
} else {
this.sendToWebView({
type: 'activeEditorChanged',
data: { fileName: null, filePath: null, selection: null },
});
}
} catch (error) {
console.error(
'[EditorMessageHandler] Failed to get active editor:',
error,
);
}
}
/**
* Focus on active editor
*/
private async handleFocusActiveEditor(): Promise<void> {
try {
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor) {
await vscode.window.showTextDocument(activeEditor.document, {
viewColumn: activeEditor.viewColumn,
preserveFocus: false,
});
} else {
// If no active editor, show file picker
const uri = await vscode.window.showOpenDialog({
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
canSelectMany: false,
canSelectFiles: true,
canSelectFolders: false,
openLabel: 'Open',
});
if (uri && uri.length > 0) {
await vscode.window.showTextDocument(uri[0]);
}
}
} catch (error) {
console.error(
'[EditorMessageHandler] Failed to focus active editor:',
error,
);
vscode.window.showErrorMessage(`Failed to focus editor: ${error}`);
}
}
}

View File

@@ -0,0 +1,326 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import { getFileName } from '../../utils/webviewUtils.js';
/**
* File message handler
* Handles all file-related messages
*/
export class FileMessageHandler extends BaseMessageHandler {
canHandle(messageType: string): boolean {
return [
'attachFile',
'showContextPicker',
'getWorkspaceFiles',
'openFile',
'openDiff',
].includes(messageType);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
const data = message.data as Record<string, unknown> | undefined;
switch (message.type) {
case 'attachFile':
await this.handleAttachFile();
break;
case 'showContextPicker':
await this.handleShowContextPicker();
break;
case 'getWorkspaceFiles':
await this.handleGetWorkspaceFiles(data?.query as string | undefined);
break;
case 'openFile':
await this.handleOpenFile(data?.path as string | undefined);
break;
case 'openDiff':
await this.handleOpenDiff(data);
break;
default:
console.warn(
'[FileMessageHandler] Unknown message type:',
message.type,
);
break;
}
}
/**
* Handle attach file request
*/
private async handleAttachFile(): Promise<void> {
try {
const uris = await vscode.window.showOpenDialog({
canSelectMany: false,
canSelectFiles: true,
canSelectFolders: false,
openLabel: 'Attach',
});
if (uris && uris.length > 0) {
const uri = uris[0];
const fileName = getFileName(uri.fsPath);
this.sendToWebView({
type: 'fileAttached',
data: {
id: `file-${Date.now()}`,
type: 'file',
name: fileName,
value: uri.fsPath,
},
});
}
} catch (error) {
console.error('[FileMessageHandler] Failed to attach file:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to attach file: ${error}` },
});
}
}
/**
* Handle show context picker request
*/
private async handleShowContextPicker(): Promise<void> {
try {
const items: vscode.QuickPickItem[] = [];
// Add current file
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor) {
const fileName = getFileName(activeEditor.document.uri.fsPath);
items.push({
label: `$(file) ${fileName}`,
description: 'Current file',
detail: activeEditor.document.uri.fsPath,
});
}
// Add file picker option
items.push({
label: '$(file) File...',
description: 'Choose a file to attach',
});
// Add workspace files option
items.push({
label: '$(search) Search files...',
description: 'Search workspace files',
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: 'Attach context',
matchOnDescription: true,
matchOnDetail: true,
});
if (selected) {
if (selected.label.includes('Current file') && activeEditor) {
const fileName = getFileName(activeEditor.document.uri.fsPath);
this.sendToWebView({
type: 'fileAttached',
data: {
id: `file-${Date.now()}`,
type: 'file',
name: fileName,
value: activeEditor.document.uri.fsPath,
},
});
} else if (selected.label.includes('File...')) {
await this.handleAttachFile();
} else if (selected.label.includes('Search files')) {
const uri = await vscode.window.showOpenDialog({
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
canSelectMany: false,
canSelectFiles: true,
canSelectFolders: false,
openLabel: 'Attach',
});
if (uri && uri.length > 0) {
const fileName = getFileName(uri[0].fsPath);
this.sendToWebView({
type: 'fileAttached',
data: {
id: `file-${Date.now()}`,
type: 'file',
name: fileName,
value: uri[0].fsPath,
},
});
}
}
}
} catch (error) {
console.error(
'[FileMessageHandler] Failed to show context picker:',
error,
);
this.sendToWebView({
type: 'error',
data: { message: `Failed to show context picker: ${error}` },
});
}
}
/**
* Get workspace files
*/
private async handleGetWorkspaceFiles(query?: string): Promise<void> {
try {
const files: Array<{
id: string;
label: string;
description: string;
path: string;
}> = [];
const addedPaths = new Set<string>();
const addFile = (uri: vscode.Uri, isCurrentFile = false) => {
if (addedPaths.has(uri.fsPath)) {
return;
}
const fileName = getFileName(uri.fsPath);
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
const relativePath = workspaceFolder
? vscode.workspace.asRelativePath(uri, false)
: uri.fsPath;
// Filter by query if provided
if (
query &&
!fileName.toLowerCase().includes(query.toLowerCase()) &&
!relativePath.toLowerCase().includes(query.toLowerCase())
) {
return;
}
files.push({
id: isCurrentFile ? 'current-file' : uri.fsPath,
label: fileName,
description: relativePath,
path: uri.fsPath,
});
addedPaths.add(uri.fsPath);
};
// Search or show recent files
if (query) {
const uris = await vscode.workspace.findFiles(
`**/*${query}*`,
'**/node_modules/**',
50,
);
for (const uri of uris) {
addFile(uri);
}
} else {
// Add current active file first
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor) {
addFile(activeEditor.document.uri, true);
}
// Add all open tabs
const tabGroups = vscode.window.tabGroups.all;
for (const tabGroup of tabGroups) {
for (const tab of tabGroup.tabs) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
if (input && input.uri instanceof vscode.Uri) {
addFile(input.uri);
}
}
}
// If not enough files, add some workspace files
if (files.length < 10) {
const recentUris = await vscode.workspace.findFiles(
'**/*',
'**/node_modules/**',
20,
);
for (const uri of recentUris) {
if (files.length >= 20) {
break;
}
addFile(uri);
}
}
}
this.sendToWebView({
type: 'workspaceFiles',
data: { files },
});
} catch (error) {
console.error(
'[FileMessageHandler] Failed to get workspace files:',
error,
);
this.sendToWebView({
type: 'error',
data: { message: `Failed to get workspace files: ${error}` },
});
}
}
/**
* Open file
*/
private async handleOpenFile(path?: string): Promise<void> {
if (!path) {
console.warn('[FileMessageHandler] No path provided for openFile');
return;
}
try {
const uri = vscode.Uri.file(path);
await vscode.window.showTextDocument(uri, {
preview: false,
preserveFocus: false,
});
} catch (error) {
console.error('[FileMessageHandler] Failed to open file:', error);
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
}
}
/**
* Open diff view
*/
private async handleOpenDiff(
data: Record<string, unknown> | undefined,
): Promise<void> {
if (!data) {
console.warn('[FileMessageHandler] No data provided for openDiff');
return;
}
try {
await vscode.commands.executeCommand('qwenCode.showDiff', {
path: (data.path as string) || '',
oldText: (data.oldText as string) || '',
newText: (data.newText as string) || '',
});
} catch (error) {
console.error('[FileMessageHandler] Failed to open diff:', error);
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
}
}
}

View File

@@ -0,0 +1,168 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { IMessageHandler } from './BaseMessageHandler.js';
import type { QwenAgentManager } from '../../agents/qwenAgentManager.js';
import type { ConversationStore } from '../../storage/conversationStore.js';
import { SessionMessageHandler } from './SessionMessageHandler.js';
import { FileMessageHandler } from './FileMessageHandler.js';
import { EditorMessageHandler } from './EditorMessageHandler.js';
import { AuthMessageHandler } from './AuthMessageHandler.js';
import { SettingsMessageHandler } from './SettingsMessageHandler.js';
/**
* Message Router
* Routes messages to appropriate handlers
*/
export class MessageRouter {
private handlers: IMessageHandler[] = [];
private sessionHandler: SessionMessageHandler;
private authHandler: AuthMessageHandler;
private currentConversationId: string | null = null;
private permissionHandler:
| ((message: { type: string; data: { optionId: string } }) => void)
| null = null;
constructor(
agentManager: QwenAgentManager,
conversationStore: ConversationStore,
currentConversationId: string | null,
sendToWebView: (message: unknown) => void,
) {
this.currentConversationId = currentConversationId;
// Initialize all handlers
this.sessionHandler = new SessionMessageHandler(
agentManager,
conversationStore,
currentConversationId,
sendToWebView,
);
const fileHandler = new FileMessageHandler(
agentManager,
conversationStore,
currentConversationId,
sendToWebView,
);
const editorHandler = new EditorMessageHandler(
agentManager,
conversationStore,
currentConversationId,
sendToWebView,
);
this.authHandler = new AuthMessageHandler(
agentManager,
conversationStore,
currentConversationId,
sendToWebView,
);
const settingsHandler = new SettingsMessageHandler(
agentManager,
conversationStore,
currentConversationId,
sendToWebView,
);
// Register handlers in order of priority
this.handlers = [
this.sessionHandler,
fileHandler,
editorHandler,
this.authHandler,
settingsHandler,
];
}
/**
* Route message to appropriate handler
*/
async route(message: { type: string; data?: unknown }): Promise<void> {
console.log('[MessageRouter] Routing message:', message.type);
// Handle permission response specially
if (message.type === 'permissionResponse') {
if (this.permissionHandler) {
this.permissionHandler(
message as { type: string; data: { optionId: string } },
);
}
return;
}
// Find appropriate handler
const handler = this.handlers.find((h) => h.canHandle(message.type));
if (handler) {
try {
await handler.handle(message);
} catch (error) {
console.error('[MessageRouter] Handler error:', error);
throw error;
}
} else {
console.warn(
'[MessageRouter] No handler found for message type:',
message.type,
);
}
}
/**
* Set current conversation ID
*/
setCurrentConversationId(id: string | null): void {
this.currentConversationId = id;
// Update all handlers
this.handlers.forEach((handler) => {
if ('setCurrentConversationId' in handler) {
(
handler as { setCurrentConversationId: (id: string | null) => void }
).setCurrentConversationId(id);
}
});
}
/**
* Get current conversation ID
*/
getCurrentConversationId(): string | null {
return this.currentConversationId;
}
/**
* Set permission handler
*/
setPermissionHandler(
handler: (message: { type: string; data: { optionId: string } }) => void,
): void {
this.permissionHandler = handler;
}
/**
* Set login handler
*/
setLoginHandler(handler: () => Promise<void>): void {
this.authHandler.setLoginHandler(handler);
}
/**
* Append stream content
*/
appendStreamContent(chunk: string): void {
this.sessionHandler.appendStreamContent(chunk);
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.sessionHandler.getIsSavingCheckpoint();
}
}

View File

@@ -0,0 +1,590 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ChatMessage } from '../../agents/qwenAgentManager.js';
/**
* Session message handler
* Handles all session-related messages
*/
export class SessionMessageHandler extends BaseMessageHandler {
private currentStreamContent = '';
private isSavingCheckpoint = false;
canHandle(messageType: string): boolean {
return [
'sendMessage',
'newQwenSession',
'switchQwenSession',
'getQwenSessions',
'saveSession',
'resumeSession',
].includes(messageType);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
const data = message.data as Record<string, unknown> | undefined;
switch (message.type) {
case 'sendMessage':
await this.handleSendMessage(
(data?.text as string) || '',
data?.context as
| Array<{
type: string;
name: string;
value: string;
startLine?: number;
endLine?: number;
}>
| undefined,
data?.fileContext as
| {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
}
| undefined,
);
break;
case 'newQwenSession':
await this.handleNewQwenSession();
break;
case 'switchQwenSession':
await this.handleSwitchQwenSession((data?.sessionId as string) || '');
break;
case 'getQwenSessions':
await this.handleGetQwenSessions();
break;
case 'saveSession':
await this.handleSaveSession((data?.tag as string) || '');
break;
case 'resumeSession':
await this.handleResumeSession((data?.sessionId as string) || '');
break;
default:
console.warn(
'[SessionMessageHandler] Unknown message type:',
message.type,
);
break;
}
}
/**
* Get current stream content
*/
getCurrentStreamContent(): string {
return this.currentStreamContent;
}
/**
* Append stream content
*/
appendStreamContent(chunk: string): void {
this.currentStreamContent += chunk;
}
/**
* Reset stream content
*/
resetStreamContent(): void {
this.currentStreamContent = '';
}
/**
* Check if saving checkpoint
*/
getIsSavingCheckpoint(): boolean {
return this.isSavingCheckpoint;
}
/**
* Handle send message request
*/
private async handleSendMessage(
text: string,
context?: Array<{
type: string;
name: string;
value: string;
startLine?: number;
endLine?: number;
}>,
fileContext?: {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
},
): Promise<void> {
console.log('[SessionMessageHandler] handleSendMessage called with:', text);
// Format message with file context if present
let formattedText = text;
if (context && context.length > 0) {
const contextParts = context
.map((ctx) => {
if (ctx.startLine && ctx.endLine) {
return `${ctx.value}#${ctx.startLine}${ctx.startLine !== ctx.endLine ? `-${ctx.endLine}` : ''}`;
}
return ctx.value;
})
.join('\n');
formattedText = `${contextParts}\n\n${text}`;
}
// Ensure we have an active conversation
if (!this.currentConversationId) {
console.log(
'[SessionMessageHandler] No active conversation, creating one...',
);
try {
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
this.sendToWebView({
type: 'conversationLoaded',
data: newConv,
});
} catch (error) {
const errorMsg = `Failed to create conversation: ${error}`;
console.error('[SessionMessageHandler]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
return;
}
}
if (!this.currentConversationId) {
const errorMsg =
'Failed to create conversation. Please restart the extension.';
console.error('[SessionMessageHandler]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
return;
}
// Check if this is the first message
let isFirstMessage = false;
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
isFirstMessage = !conversation || conversation.messages.length === 0;
} catch (error) {
console.error(
'[SessionMessageHandler] Failed to check conversation:',
error,
);
}
// Generate title for first message
if (isFirstMessage) {
const title = text.substring(0, 50) + (text.length > 50 ? '...' : '');
this.sendToWebView({
type: 'sessionTitleUpdated',
data: {
sessionId: this.currentConversationId,
title,
},
});
}
// Save user message
const userMessage: ChatMessage = {
role: 'user',
content: text,
timestamp: Date.now(),
};
await this.conversationStore.addMessage(
this.currentConversationId,
userMessage,
);
// Send to WebView
this.sendToWebView({
type: 'message',
data: { ...userMessage, fileContext },
});
// Check if agent is connected
if (!this.agentManager.isConnected) {
console.warn('[SessionMessageHandler] Agent not connected');
const result = await vscode.window.showWarningMessage(
'You need to login first to use Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
vscode.commands.executeCommand('qwenCode.login');
}
return;
}
// Send to agent
try {
this.resetStreamContent();
this.sendToWebView({
type: 'streamStart',
data: { timestamp: Date.now() },
});
await this.agentManager.sendMessage(formattedText);
// Save assistant message
if (this.currentStreamContent && this.currentConversationId) {
const assistantMessage: ChatMessage = {
role: 'assistant',
content: this.currentStreamContent,
timestamp: Date.now(),
};
await this.conversationStore.addMessage(
this.currentConversationId,
assistantMessage,
);
}
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now() },
});
// Auto-save checkpoint
if (this.currentConversationId) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
this.isSavingCheckpoint = true;
const result = await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
setTimeout(() => {
this.isSavingCheckpoint = false;
}, 2000);
if (result.success) {
console.log(
'[SessionMessageHandler] Checkpoint saved:',
result.tag,
);
}
} catch (error) {
console.error(
'[SessionMessageHandler] Checkpoint save failed:',
error,
);
this.isSavingCheckpoint = false;
}
}
} catch (error) {
console.error('[SessionMessageHandler] Error sending message:', error);
const errorMsg = String(error);
if (errorMsg.includes('No active ACP session')) {
const result = await vscode.window.showWarningMessage(
'You need to login first to use Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
vscode.commands.executeCommand('qwenCode.login');
}
} else {
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
}
}
}
/**
* Handle new Qwen session request
*/
private async handleNewQwenSession(): Promise<void> {
try {
console.log('[SessionMessageHandler] Creating new Qwen session...');
// Save current session before creating new one
if (this.currentConversationId && this.agentManager.isConnected) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
await this.agentManager.createNewSession(workingDir);
this.sendToWebView({
type: 'conversationCleared',
data: {},
});
} catch (error) {
console.error(
'[SessionMessageHandler] Failed to create new session:',
error,
);
this.sendToWebView({
type: 'error',
data: { message: `Failed to create new session: ${error}` },
});
}
}
/**
* Handle switch Qwen session request
*/
private async handleSwitchQwenSession(sessionId: string): Promise<void> {
try {
console.log('[SessionMessageHandler] Switching to session:', sessionId);
// Save current session before switching
if (
this.currentConversationId &&
this.currentConversationId !== sessionId &&
this.agentManager.isConnected
) {
try {
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
await this.agentManager.saveCheckpoint(
messages,
this.currentConversationId,
);
} catch (error) {
console.warn('[SessionMessageHandler] Failed to auto-save:', error);
}
}
// Get session details
let sessionDetails = null;
try {
const allSessions = await this.agentManager.getSessionList();
sessionDetails = allSessions.find(
(s: { id?: string; sessionId?: string }) =>
s.id === sessionId || s.sessionId === sessionId,
);
} catch (err) {
console.log(
'[SessionMessageHandler] Could not get session details:',
err,
);
}
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
// Try to load session via ACP
try {
const loadResponse =
await this.agentManager.loadSessionViaAcp(sessionId);
console.log(
'[SessionMessageHandler] session/load succeeded:',
loadResponse,
);
this.currentConversationId = sessionId;
const messages = await this.agentManager.getSessionMessages(sessionId);
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages, session: sessionDetails },
});
} catch (_loadError) {
console.warn(
'[SessionMessageHandler] session/load failed, using fallback',
);
// Fallback: create new session
const messages = await this.agentManager.getSessionMessages(sessionId);
try {
const newAcpSessionId =
await this.agentManager.createNewSession(workingDir);
this.currentConversationId = newAcpSessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages, session: sessionDetails },
});
vscode.window.showWarningMessage(
'Session restored from local cache. Some context may be incomplete.',
);
} catch (createError) {
console.error(
'[SessionMessageHandler] Failed to create session:',
createError,
);
throw createError;
}
}
} catch (error) {
console.error('[SessionMessageHandler] Failed to switch session:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to switch session: ${error}` },
});
}
}
/**
* Handle get Qwen sessions request
*/
private async handleGetQwenSessions(): Promise<void> {
try {
const sessions = await this.agentManager.getSessionList();
this.sendToWebView({
type: 'qwenSessionList',
data: { sessions },
});
} catch (error) {
console.error('[SessionMessageHandler] Failed to get sessions:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to get sessions: ${error}` },
});
}
}
/**
* Handle save session request
*/
private async handleSaveSession(tag: string): Promise<void> {
try {
if (!this.currentConversationId) {
throw new Error('No active conversation to save');
}
const conversation = await this.conversationStore.getConversation(
this.currentConversationId,
);
const messages = conversation?.messages || [];
// Try ACP save first
try {
const response = await this.agentManager.saveSessionViaAcp(
this.currentConversationId,
tag,
);
this.sendToWebView({
type: 'saveSessionResponse',
data: response,
});
} catch (_acpError) {
// Fallback to direct save
const response = await this.agentManager.saveSessionDirect(
messages,
tag,
);
this.sendToWebView({
type: 'saveSessionResponse',
data: response,
});
}
await this.handleGetQwenSessions();
} catch (error) {
console.error('[SessionMessageHandler] Failed to save session:', error);
this.sendToWebView({
type: 'saveSessionResponse',
data: {
success: false,
message: `Failed to save session: ${error}`,
},
});
}
}
/**
* Handle resume session request
*/
private async handleResumeSession(sessionId: string): Promise<void> {
try {
// Try ACP load first
try {
await this.agentManager.loadSessionViaAcp(sessionId);
this.currentConversationId = sessionId;
const messages = await this.agentManager.getSessionMessages(sessionId);
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
} catch (_acpError) {
// Fallback to direct load
const messages = await this.agentManager.loadSessionDirect(sessionId);
if (messages) {
this.currentConversationId = sessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
} else {
throw new Error('Failed to load session');
}
}
await this.handleGetQwenSessions();
} catch (error) {
console.error('[SessionMessageHandler] Failed to resume session:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to resume session: ${error}` },
});
}
}
}

View File

@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
/**
* Settings message handler
* Handles all settings-related messages
*/
export class SettingsMessageHandler extends BaseMessageHandler {
canHandle(messageType: string): boolean {
return ['openSettings', 'recheckCli'].includes(messageType);
}
async handle(message: { type: string; data?: unknown }): Promise<void> {
switch (message.type) {
case 'openSettings':
await this.handleOpenSettings();
break;
case 'recheckCli':
await this.handleRecheckCli();
break;
default:
console.warn(
'[SettingsMessageHandler] Unknown message type:',
message.type,
);
break;
}
}
/**
* Open settings page
*/
private async handleOpenSettings(): Promise<void> {
try {
await vscode.commands.executeCommand(
'workbench.action.openSettings',
'qwenCode',
);
} catch (error) {
console.error('[SettingsMessageHandler] Failed to open settings:', error);
vscode.window.showErrorMessage(`Failed to open settings: ${error}`);
}
}
/**
* Recheck CLI
*/
private async handleRecheckCli(): Promise<void> {
try {
await vscode.commands.executeCommand('qwenCode.recheckCli');
this.sendToWebView({
type: 'cliRechecked',
data: { success: true },
});
} catch (error) {
console.error('[SettingsMessageHandler] Failed to recheck CLI:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to recheck CLI: ${error}` },
});
}
}
}

View File

@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useRef } from 'react';
import type { VSCodeAPI } from '../hooks/useVSCode.js';
/**
* File context management Hook
* Manages active file, selection content, and workspace file list
*/
export const useFileContext = (vscode: VSCodeAPI) => {
const [activeFileName, setActiveFileName] = useState<string | null>(null);
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
const [activeSelection, setActiveSelection] = useState<{
startLine: number;
endLine: number;
} | null>(null);
const [workspaceFiles, setWorkspaceFiles] = useState<
Array<{
id: string;
label: string;
description: string;
path: string;
}>
>([]);
// File reference mapping: @filename -> full path
const fileReferenceMap = useRef<Map<string, string>>(new Map());
// Whether workspace files have been requested
const hasRequestedFilesRef = useRef(false);
// Search debounce timer
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
/**
* Request workspace files
*/
const requestWorkspaceFiles = useCallback(
(query?: string) => {
if (!hasRequestedFilesRef.current && !query) {
hasRequestedFilesRef.current = true;
}
// If there's a query, clear previous timer and set up debounce
if (query && query.length >= 1) {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
searchTimerRef.current = setTimeout(() => {
vscode.postMessage({
type: 'getWorkspaceFiles',
data: { query },
});
}, 300);
} else {
vscode.postMessage({
type: 'getWorkspaceFiles',
data: query ? { query } : {},
});
}
},
[vscode],
);
/**
* Add file reference
*/
const addFileReference = useCallback((fileName: string, filePath: string) => {
fileReferenceMap.current.set(fileName, filePath);
}, []);
/**
* Get file reference
*/
const getFileReference = useCallback(
(fileName: string) => fileReferenceMap.current.get(fileName),
[],
);
/**
* Clear file references
*/
const clearFileReferences = useCallback(() => {
fileReferenceMap.current.clear();
}, []);
/**
* Request active editor info
*/
const requestActiveEditor = useCallback(() => {
vscode.postMessage({ type: 'getActiveEditor', data: {} });
}, [vscode]);
/**
* Focus on active editor
*/
const focusActiveEditor = useCallback(() => {
vscode.postMessage({
type: 'focusActiveEditor',
data: {},
});
}, [vscode]);
return {
// State
activeFileName,
activeFilePath,
activeSelection,
workspaceFiles,
hasRequestedFiles: hasRequestedFilesRef.current,
// State setters
setActiveFileName,
setActiveFilePath,
setActiveSelection,
setWorkspaceFiles,
// File reference operations
addFileReference,
getFileReference,
clearFileReferences,
// Operations
requestWorkspaceFiles,
requestActiveEditor,
focusActiveEditor,
};
};

View File

@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useCallback } from 'react';
export interface TextMessage {
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
fileContext?: {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
};
}
/**
* Message handling Hook
* Manages message list, streaming responses, and loading state
*/
export const useMessageHandling = () => {
const [messages, setMessages] = useState<TextMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
const [loadingMessage, setLoadingMessage] = useState('');
const [currentStreamContent, setCurrentStreamContent] = useState('');
// Use ref to store current stream content, avoiding useEffect dependency issues
const currentStreamContentRef = useRef<string>('');
/**
* Add message
*/
const addMessage = useCallback((message: TextMessage) => {
setMessages((prev) => [...prev, message]);
}, []);
/**
* Clear messages
*/
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
/**
* Start streaming response
*/
const startStreaming = useCallback(() => {
setIsStreaming(true);
setCurrentStreamContent('');
currentStreamContentRef.current = '';
}, []);
/**
* Add stream chunk
*/
const appendStreamChunk = useCallback((chunk: string) => {
setCurrentStreamContent((prev) => {
const newContent = prev + chunk;
currentStreamContentRef.current = newContent;
return newContent;
});
}, []);
/**
* End streaming response
*/
const endStreaming = useCallback(() => {
// If there is streaming content, add it as complete assistant message
if (currentStreamContentRef.current) {
const assistantMessage: TextMessage = {
role: 'assistant',
content: currentStreamContentRef.current,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, assistantMessage]);
}
setIsStreaming(false);
setIsWaitingForResponse(false);
setCurrentStreamContent('');
currentStreamContentRef.current = '';
}, []);
/**
* Set waiting for response state
*/
const setWaitingForResponse = useCallback((message: string) => {
setIsWaitingForResponse(true);
setLoadingMessage(message);
}, []);
/**
* Clear waiting for response state
*/
const clearWaitingForResponse = useCallback(() => {
setIsWaitingForResponse(false);
setLoadingMessage('');
}, []);
return {
// State
messages,
isStreaming,
isWaitingForResponse,
loadingMessage,
currentStreamContent,
// Operations
addMessage,
clearMessages,
startStreaming,
appendStreamChunk,
endStreaming,
setWaitingForResponse,
clearWaitingForResponse,
setMessages,
};
};

View File

@@ -0,0 +1,136 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useMemo } from 'react';
import type { VSCodeAPI } from '../hooks/useVSCode.js';
/**
* Session management Hook
* Manages session list, current session, session switching, and search
*/
export const useSessionManagement = (vscode: VSCodeAPI) => {
const [qwenSessions, setQwenSessions] = useState<
Array<Record<string, unknown>>
>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [currentSessionTitle, setCurrentSessionTitle] =
useState<string>('Past Conversations');
const [showSessionSelector, setShowSessionSelector] = useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
/**
* Filter session list
*/
const filteredSessions = useMemo(() => {
if (!sessionSearchQuery.trim()) {
return qwenSessions;
}
const query = sessionSearchQuery.toLowerCase();
return qwenSessions.filter((session) => {
const title = (
(session.title as string) ||
(session.name as string) ||
''
).toLowerCase();
return title.includes(query);
});
}, [qwenSessions, sessionSearchQuery]);
/**
* Load session list
*/
const handleLoadQwenSessions = useCallback(() => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
setShowSessionSelector(true);
}, [vscode]);
/**
* Create new session
*/
const handleNewQwenSession = useCallback(() => {
vscode.postMessage({ type: 'openNewChatTab', data: {} });
setShowSessionSelector(false);
}, [vscode]);
/**
* Switch session
*/
const handleSwitchSession = useCallback(
(sessionId: string) => {
if (sessionId === currentSessionId) {
console.log('[useSessionManagement] Already on this session, ignoring');
setShowSessionSelector(false);
return;
}
console.log('[useSessionManagement] Switching to session:', sessionId);
vscode.postMessage({
type: 'switchQwenSession',
data: { sessionId },
});
},
[currentSessionId, vscode],
);
/**
* Save session
*/
const handleSaveSession = useCallback(
(tag: string) => {
vscode.postMessage({
type: 'saveSession',
data: { tag },
});
},
[vscode],
);
/**
* 处理Save session响应
*/
const handleSaveSessionResponse = useCallback(
(response: { success: boolean; message?: string }) => {
if (response.success) {
if (response.message) {
const tagMatch = response.message.match(/tag: (.+)$/);
if (tagMatch) {
setSavedSessionTags((prev) => [...prev, tagMatch[1]]);
}
}
} else {
console.error('Failed to save session:', response.message);
}
},
[],
);
return {
// State
qwenSessions,
currentSessionId,
currentSessionTitle,
showSessionSelector,
sessionSearchQuery,
filteredSessions,
savedSessionTags,
// State setters
setQwenSessions,
setCurrentSessionId,
setCurrentSessionTitle,
setShowSessionSelector,
setSessionSearchQuery,
setSavedSessionTags,
// Operations
handleLoadQwenSessions,
handleNewQwenSession,
handleSwitchSession,
handleSaveSession,
handleSaveSessionResponse,
};
};

View File

@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react';
import type { VSCodeAPI } from './useVSCode.js';
import { getRandomLoadingMessage } from '../constants/loadingMessages.js';
interface UseMessageSubmitProps {
vscode: VSCodeAPI;
inputText: string;
setInputText: (text: string) => void;
inputFieldRef: React.RefObject<HTMLDivElement>;
isStreaming: boolean;
fileContext: {
getFileReference: (fileName: string) => string | undefined;
activeFilePath: string | null;
activeFileName: string | null;
activeSelection: { startLine: number; endLine: number } | null;
clearFileReferences: () => void;
};
messageHandling: {
setWaitingForResponse: (message: string) => void;
};
}
/**
* Message submit Hook
* Handles message submission logic and context parsing
*/
export const useMessageSubmit = ({
vscode,
inputText,
setInputText,
inputFieldRef,
isStreaming,
fileContext,
messageHandling,
}: UseMessageSubmitProps) => {
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!inputText.trim() || isStreaming) {
return;
}
// Handle /login command
if (inputText.trim() === '/login') {
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
}
vscode.postMessage({
type: 'login',
data: {},
});
return;
}
messageHandling.setWaitingForResponse(getRandomLoadingMessage());
// Parse @file references from input text
const context: Array<{
type: string;
name: string;
value: string;
startLine?: number;
endLine?: number;
}> = [];
const fileRefPattern = /@([^\s]+)/g;
let match;
while ((match = fileRefPattern.exec(inputText)) !== null) {
const fileName = match[1];
const filePath = fileContext.getFileReference(fileName);
if (filePath) {
context.push({
type: 'file',
name: fileName,
value: filePath,
});
}
}
// Add active file selection context if present
if (fileContext.activeFilePath) {
const fileName = fileContext.activeFileName || 'current file';
context.push({
type: 'file',
name: fileName,
value: fileContext.activeFilePath,
startLine: fileContext.activeSelection?.startLine,
endLine: fileContext.activeSelection?.endLine,
});
}
let fileContextForMessage:
| {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
}
| undefined;
if (fileContext.activeFilePath && fileContext.activeFileName) {
fileContextForMessage = {
fileName: fileContext.activeFileName,
filePath: fileContext.activeFilePath,
startLine: fileContext.activeSelection?.startLine,
endLine: fileContext.activeSelection?.endLine,
};
}
vscode.postMessage({
type: 'sendMessage',
data: {
text: inputText,
context: context.length > 0 ? context : undefined,
fileContext: fileContextForMessage,
},
});
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
}
fileContext.clearFileReferences();
},
[
inputText,
isStreaming,
setInputText,
inputFieldRef,
vscode,
fileContext,
messageHandling,
],
);
return { handleSubmit };
};

View File

@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import type { ToolCallData } from '../components/ToolCall.js';
import type { ToolCallUpdate } from '../types/toolCall.js';
/**
* Tool call management Hook
* Manages tool call states and updates
*/
export const useToolCalls = () => {
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
new Map(),
);
/**
* Handle tool call update
*/
const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => {
setToolCalls((prevToolCalls) => {
const newMap = new Map(prevToolCalls);
const existing = newMap.get(update.toolCallId);
const safeTitle = (title: unknown): string => {
if (typeof title === 'string') {
return title;
}
if (title && typeof title === 'object') {
return JSON.stringify(title);
}
return 'Tool Call';
};
if (update.type === 'tool_call') {
const content = update.content?.map((item) => ({
type: item.type as 'content' | 'diff',
content: item.content,
path: item.path,
oldText: item.oldText,
newText: item.newText,
}));
newMap.set(update.toolCallId, {
toolCallId: update.toolCallId,
kind: update.kind || 'other',
title: safeTitle(update.title),
status: update.status || 'pending',
rawInput: update.rawInput as string | object | undefined,
content,
locations: update.locations,
});
} else if (update.type === 'tool_call_update') {
const updatedContent = update.content
? update.content.map((item) => ({
type: item.type as 'content' | 'diff',
content: item.content,
path: item.path,
oldText: item.oldText,
newText: item.newText,
}))
: undefined;
if (existing) {
const mergedContent = updatedContent
? [...(existing.content || []), ...updatedContent]
: existing.content;
newMap.set(update.toolCallId, {
...existing,
...(update.kind && { kind: update.kind }),
...(update.title && { title: safeTitle(update.title) }),
...(update.status && { status: update.status }),
content: mergedContent,
...(update.locations && { locations: update.locations }),
});
} else {
newMap.set(update.toolCallId, {
toolCallId: update.toolCallId,
kind: update.kind || 'other',
title: update.title ? safeTitle(update.title) : '',
status: update.status || 'pending',
rawInput: update.rawInput as string | object | undefined,
content: updatedContent,
locations: update.locations,
});
}
}
return newMap;
});
}, []);
/**
* Clear all tool calls
*/
const clearToolCalls = useCallback(() => {
setToolCalls(new Map());
}, []);
/**
* Get in-progress tool calls
*/
const inProgressToolCalls = Array.from(toolCalls.values()).filter(
(toolCall) =>
toolCall.status === 'pending' || toolCall.status === 'in_progress',
);
/**
* Get completed tool calls
*/
const completedToolCalls = Array.from(toolCalls.values()).filter(
(toolCall) =>
toolCall.status === 'completed' || toolCall.status === 'failed',
);
return {
toolCalls,
inProgressToolCalls,
completedToolCalls,
handleToolCallUpdate,
clearToolCalls,
};
};

View File

@@ -13,14 +13,14 @@ export interface VSCodeAPI {
declare const acquireVsCodeApi: () => VSCodeAPI;
/**
* 模块级别的 VS Code API 实例缓存
* acquireVsCodeApi() 只能调用一次,必须在模块级别缓存
* Module-level VS Code API instance cache
* acquireVsCodeApi() can only be called once, must be cached at module level
*/
let vscodeApiInstance: VSCodeAPI | null = null;
/**
* 获取 VS Code API 实例
* 使用模块级别缓存确保 acquireVsCodeApi() 只被调用一次
* Get VS Code API instance
* Uses module-level cache to ensure acquireVsCodeApi() is only called once
*/
function getVSCodeAPI(): VSCodeAPI {
if (vscodeApiInstance) {
@@ -47,7 +47,7 @@ function getVSCodeAPI(): VSCodeAPI {
/**
* Hook to get VS Code API
* 多个组件可以安全地调用此 hookAPI 实例会被复用
* Multiple components can safely call this hook, API instance will be reused
*/
export function useVSCode(): VSCodeAPI {
return getVSCodeAPI();

View File

@@ -0,0 +1,380 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef, useCallback } from 'react';
import type { Conversation } from '../../storage/conversationStore.js';
import type {
PermissionOption,
ToolCall as PermissionToolCall,
} from '../components/PermissionRequest.js';
import type { PlanEntry } from '../components/PlanDisplay.js';
import type { ToolCallUpdate } from '../types/toolCall.js';
interface UseWebViewMessagesProps {
// Session management
sessionManagement: {
currentSessionId: string | null;
setQwenSessions: (sessions: Array<Record<string, unknown>>) => void;
setCurrentSessionId: (id: string | null) => void;
setCurrentSessionTitle: (title: string) => void;
setShowSessionSelector: (show: boolean) => void;
handleSaveSessionResponse: (response: {
success: boolean;
message?: string;
}) => void;
};
// File context
fileContext: {
setActiveFileName: (name: string | null) => void;
setActiveFilePath: (path: string | null) => void;
setActiveSelection: (
selection: { startLine: number; endLine: number } | null,
) => void;
setWorkspaceFiles: (
files: Array<{
id: string;
label: string;
description: string;
path: string;
}>,
) => void;
addFileReference: (name: string, path: string) => void;
};
// Message handling
messageHandling: {
setMessages: (
messages: Array<{
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
fileContext?: {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
};
}>,
) => void;
addMessage: (message: {
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
}) => void;
clearMessages: () => void;
startStreaming: () => void;
appendStreamChunk: (chunk: string) => void;
endStreaming: () => void;
clearWaitingForResponse: () => void;
};
// Tool calls
handleToolCallUpdate: (update: ToolCallUpdate) => void;
clearToolCalls: () => void;
// Plan
setPlanEntries: (entries: PlanEntry[]) => void;
// Permission
handlePermissionRequest: (request: {
options: PermissionOption[];
toolCall: PermissionToolCall;
}) => void;
// Input
inputFieldRef: React.RefObject<HTMLDivElement>;
setInputText: (text: string) => void;
}
/**
* WebView message handling Hook
* Handles all messages from VSCode Extension uniformly
*/
export const useWebViewMessages = ({
sessionManagement,
fileContext,
messageHandling,
handleToolCallUpdate,
clearToolCalls,
setPlanEntries,
handlePermissionRequest,
inputFieldRef,
setInputText,
}: UseWebViewMessagesProps) => {
// Use ref to store callbacks to avoid useEffect dependency issues
const handlersRef = useRef({
sessionManagement,
fileContext,
messageHandling,
handleToolCallUpdate,
clearToolCalls,
setPlanEntries,
handlePermissionRequest,
});
// Update refs
useEffect(() => {
handlersRef.current = {
sessionManagement,
fileContext,
messageHandling,
handleToolCallUpdate,
clearToolCalls,
setPlanEntries,
handlePermissionRequest,
};
});
const handleMessage = useCallback(
(event: MessageEvent) => {
const message = event.data;
const handlers = handlersRef.current;
switch (message.type) {
case 'conversationLoaded': {
const conversation = message.data as Conversation;
handlers.messageHandling.setMessages(conversation.messages);
break;
}
case 'message': {
handlers.messageHandling.addMessage(message.data);
break;
}
case 'streamStart':
handlers.messageHandling.startStreaming();
break;
case 'streamChunk': {
handlers.messageHandling.appendStreamChunk(message.data.chunk);
break;
}
case 'thoughtChunk': {
const thinkingMessage = {
role: 'thinking' as const,
content: message.data.content || message.data.chunk || '',
timestamp: Date.now(),
};
handlers.messageHandling.addMessage(thinkingMessage);
break;
}
case 'streamEnd':
handlers.messageHandling.endStreaming();
break;
case 'error':
handlers.messageHandling.clearWaitingForResponse();
break;
case 'permissionRequest': {
handlers.handlePermissionRequest(message.data);
const permToolCall = message.data?.toolCall as {
toolCallId?: string;
kind?: string;
title?: string;
status?: string;
content?: unknown[];
locations?: Array<{ path: string; line?: number | null }>;
};
if (permToolCall?.toolCallId) {
let kind = permToolCall.kind || 'execute';
if (permToolCall.title) {
const title = permToolCall.title.toLowerCase();
if (title.includes('touch') || title.includes('echo')) {
kind = 'execute';
} else if (title.includes('read') || title.includes('cat')) {
kind = 'read';
} else if (title.includes('write') || title.includes('edit')) {
kind = 'edit';
}
}
const normalizedStatus = (
permToolCall.status === 'pending' ||
permToolCall.status === 'in_progress' ||
permToolCall.status === 'completed' ||
permToolCall.status === 'failed'
? permToolCall.status
: 'pending'
) as ToolCallUpdate['status'];
handlers.handleToolCallUpdate({
type: 'tool_call',
toolCallId: permToolCall.toolCallId,
kind,
title: permToolCall.title,
status: normalizedStatus,
content: permToolCall.content as ToolCallUpdate['content'],
locations: permToolCall.locations,
});
}
break;
}
case 'plan':
if (message.data.entries && Array.isArray(message.data.entries)) {
handlers.setPlanEntries(message.data.entries as PlanEntry[]);
}
break;
case 'toolCall':
case 'toolCallUpdate': {
const toolCallData = message.data;
if (toolCallData.sessionUpdate && !toolCallData.type) {
toolCallData.type = toolCallData.sessionUpdate;
}
handlers.handleToolCallUpdate(toolCallData);
break;
}
case 'qwenSessionList': {
const sessions = message.data.sessions || [];
handlers.sessionManagement.setQwenSessions(sessions);
if (
handlers.sessionManagement.currentSessionId &&
sessions.length > 0
) {
const currentSession = sessions.find(
(s: Record<string, unknown>) =>
(s.id as string) ===
handlers.sessionManagement.currentSessionId ||
(s.sessionId as string) ===
handlers.sessionManagement.currentSessionId,
);
if (currentSession) {
const title =
(currentSession.title as string) ||
(currentSession.name as string) ||
'Past Conversations';
handlers.sessionManagement.setCurrentSessionTitle(title);
}
}
break;
}
case 'qwenSessionSwitched':
handlers.sessionManagement.setShowSessionSelector(false);
if (message.data.sessionId) {
handlers.sessionManagement.setCurrentSessionId(
message.data.sessionId as string,
);
}
if (message.data.session) {
const session = message.data.session as Record<string, unknown>;
const title =
(session.title as string) ||
(session.name as string) ||
'Past Conversations';
handlers.sessionManagement.setCurrentSessionTitle(title);
}
if (message.data.messages) {
handlers.messageHandling.setMessages(message.data.messages);
} else {
handlers.messageHandling.clearMessages();
}
handlers.clearToolCalls();
handlers.setPlanEntries([]);
break;
case 'conversationCleared':
handlers.messageHandling.clearMessages();
handlers.clearToolCalls();
handlers.sessionManagement.setCurrentSessionId(null);
handlers.sessionManagement.setCurrentSessionTitle(
'Past Conversations',
);
break;
case 'sessionTitleUpdated': {
const sessionId = message.data?.sessionId as string;
const title = message.data?.title as string;
if (sessionId && title) {
handlers.sessionManagement.setCurrentSessionId(sessionId);
handlers.sessionManagement.setCurrentSessionTitle(title);
}
break;
}
case 'activeEditorChanged': {
const fileName = message.data?.fileName as string | null;
const filePath = message.data?.filePath as string | null;
const selection = message.data?.selection as {
startLine: number;
endLine: number;
} | null;
handlers.fileContext.setActiveFileName(fileName);
handlers.fileContext.setActiveFilePath(filePath);
handlers.fileContext.setActiveSelection(selection);
break;
}
case 'fileAttached': {
const attachment = message.data as {
id: string;
type: string;
name: string;
value: string;
};
handlers.fileContext.addFileReference(
attachment.name,
attachment.value,
);
if (inputFieldRef.current) {
const currentText = inputFieldRef.current.textContent || '';
const newText = currentText
? `${currentText} @${attachment.name} `
: `@${attachment.name} `;
inputFieldRef.current.textContent = newText;
setInputText(newText);
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(inputFieldRef.current);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}
break;
}
case 'workspaceFiles': {
const files = message.data?.files as Array<{
id: string;
label: string;
description: string;
path: string;
}>;
if (files) {
handlers.fileContext.setWorkspaceFiles(files);
}
break;
}
case 'saveSessionResponse': {
handlers.sessionManagement.handleSaveSessionResponse(message.data);
break;
}
default:
break;
}
},
[inputFieldRef, setInputText],
);
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [handleMessage]);
};

View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Tool call update type
*/
export interface ToolCallUpdate {
type: 'tool_call' | 'tool_call_update';
toolCallId: string;
kind?: string;
title?: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
content?: {
type: string;
text?: string;
[key: string]: unknown;
};
path?: string;
oldText?: string | null;
newText?: string;
[key: string]: unknown;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
}
/**
* Edit mode type
*/
export type EditMode = 'ask' | 'auto' | 'plan';

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface SessionGroup {
label: string;
sessions: Array<Record<string, unknown>>;
}
/**
* Group sessions by date (matching Claude Code)
*
* @param sessions - Array of session objects
* @returns Array of grouped sessions
*/
export const groupSessionsByDate = (
sessions: Array<Record<string, unknown>>,
): SessionGroup[] => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const groups: {
[key: string]: Array<Record<string, unknown>>;
} = {
Today: [],
Yesterday: [],
'This Week': [],
Older: [],
};
sessions.forEach((session) => {
const timestamp =
(session.lastUpdated as string) || (session.startTime as string) || '';
if (!timestamp) {
groups['Older'].push(session);
return;
}
const sessionDate = new Date(timestamp);
const sessionDay = new Date(
sessionDate.getFullYear(),
sessionDate.getMonth(),
sessionDate.getDate(),
);
if (sessionDay.getTime() === today.getTime()) {
groups['Today'].push(session);
} else if (sessionDay.getTime() === yesterday.getTime()) {
groups['Yesterday'].push(session);
} else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) {
groups['This Week'].push(session);
} else {
groups['Older'].push(session);
}
});
return Object.entries(groups)
.filter(([, sessions]) => sessions.length > 0)
.map(([label, sessions]) => ({ label, sessions }));
};

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Time ago formatter (matching Claude Code)
*
* @param timestamp - ISO timestamp string
* @returns Formatted time string
*/
export const getTimeAgo = (timestamp: string): string => {
if (!timestamp) {
return '';
}
const now = new Date().getTime();
const then = new Date(timestamp).getTime();
const diffMs = now - then;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) {
return 'now';
}
if (diffMins < 60) {
return `${diffMins}m`;
}
if (diffHours < 24) {
return `${diffHours}h`;
}
if (diffDays === 1) {
return 'Yesterday';
}
if (diffDays < 7) {
return `${diffDays}d`;
}
return new Date(timestamp).toLocaleDateString();
};