Compare commits

..

2 Commits

Author SHA1 Message Date
DragonnZhang
4c8414488f refactor: reorder feedback options and improve dialog feedback timestamp handling 2026-01-23 18:55:43 +08:00
DragonnZhang
6327e35a14 feat: implement persistent feedback prompting with temporary dismissal options
Add 'Fine' and 'Dismiss' options to feedback dialogs that allow temporary
dismissal without permanently closing the feedback request. Only numerical
ratings (0, 1, 2, 3) will permanently close feedback dialogs, while all
other inputs result in temporary dismissal with persistent re-prompting.

This ensures feedback collection reliability while respecting user workflow
by allowing users to temporarily dismiss prompts when busy and providing
feedback when ready.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 16:52:40 +08:00
21 changed files with 100 additions and 1827 deletions

View File

@@ -298,7 +298,9 @@ export default {
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Fine: 'In Ordnung',
Good: 'Gut',
Dismiss: 'Ignorieren',
'Not Sure Yet': 'Noch nicht sicher',
'Any other key': 'Beliebige andere Taste',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',

View File

@@ -315,7 +315,9 @@ export default {
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Fine: 'Fine',
Good: 'Good',
Dismiss: 'Dismiss',
'Not Sure Yet': 'Not Sure Yet',
'Any other key': 'Any other key',
'Disable Loading Phrases': 'Disable Loading Phrases',

View File

@@ -319,7 +319,9 @@ export default {
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Fine: 'Нормально',
Good: 'Хорошо',
Dismiss: 'Отклонить',
'Not Sure Yet': 'Пока не уверен',
'Any other key': 'Любая другая клавиша',
'Disable Loading Phrases': 'Отключить фразы при загрузке',

View File

@@ -305,7 +305,9 @@ export default {
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Fine: '还行',
Good: '满意',
Dismiss: '忽略',
'Not Sure Yet': '暂不评价',
'Any other key': '任意其他键',
'Disable Loading Phrases': '禁用加载短语',

View File

@@ -39,7 +39,6 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { insightCommand } from '../ui/commands/insightCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -89,7 +88,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
vimCommand,
setupGithubCommand,
terminalSetupCommand,
insightCommand,
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View File

@@ -1,324 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs/promises';
import path from 'path';
import { read as readJsonlFile } from '@qwen-code/qwen-code-core';
import type {
InsightData,
HeatMapData,
TokenUsageData,
AchievementData,
StreakData,
} from '../types/StaticInsightTypes.js';
import type { ChatRecord } from '@qwen-code/qwen-code-core';
export class DataProcessor {
// Helper function to format date as YYYY-MM-DD
private formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
// Calculate streaks from activity dates
private calculateStreaks(dates: string[]): StreakData {
if (dates.length === 0) {
return { currentStreak: 0, longestStreak: 0, dates: [] };
}
// Convert string dates to Date objects and sort them
const dateObjects = dates.map((dateStr) => new Date(dateStr));
dateObjects.sort((a, b) => a.getTime() - b.getTime());
let currentStreak = 1;
let maxStreak = 1;
let currentDate = new Date(dateObjects[0]);
currentDate.setHours(0, 0, 0, 0); // Normalize to start of day
for (let i = 1; i < dateObjects.length; i++) {
const nextDate = new Date(dateObjects[i]);
nextDate.setHours(0, 0, 0, 0); // Normalize to start of day
// Calculate difference in days
const diffDays = Math.floor(
(nextDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays === 1) {
// Consecutive day
currentStreak++;
maxStreak = Math.max(maxStreak, currentStreak);
} else if (diffDays > 1) {
// Gap in streak
currentStreak = 1;
}
// If diffDays === 0, same day, so streak continues
currentDate = nextDate;
}
// Check if the streak is still ongoing (if last activity was yesterday or today)
const today = new Date();
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (
currentDate.getTime() === today.getTime() ||
currentDate.getTime() === yesterday.getTime()
) {
// The streak might still be active, so we don't reset it
}
return {
currentStreak,
longestStreak: maxStreak,
dates,
};
}
// Calculate achievements based on user behavior
private calculateAchievements(
activeHours: { [hour: number]: number },
heatmap: HeatMapData,
_tokenUsage: TokenUsageData,
): AchievementData[] {
const achievements: AchievementData[] = [];
// Total activities
const totalActivities = Object.values(heatmap).reduce(
(sum, count) => sum + count,
0,
);
// Total sessions
const totalSessions = Object.keys(heatmap).length;
// Calculate percentage of activity per hour
const totalHourlyActivity = Object.values(activeHours).reduce(
(sum, count) => sum + count,
0,
);
if (totalHourlyActivity > 0) {
// Midnight debugger: 20% of sessions happen between 12AM-5AM
const midnightActivity =
(activeHours[0] || 0) +
(activeHours[1] || 0) +
(activeHours[2] || 0) +
(activeHours[3] || 0) +
(activeHours[4] || 0) +
(activeHours[5] || 0);
if (midnightActivity / totalHourlyActivity >= 0.2) {
achievements.push({
id: 'midnight-debugger',
name: 'Midnight Debugger',
description: '20% of your sessions happen between 12AM-5AM',
});
}
// Morning coder: 20% of sessions happen between 6AM-9AM
const morningActivity =
(activeHours[6] || 0) +
(activeHours[7] || 0) +
(activeHours[8] || 0) +
(activeHours[9] || 0);
if (morningActivity / totalHourlyActivity >= 0.2) {
achievements.push({
id: 'morning-coder',
name: 'Morning Coder',
description: '20% of your sessions happen between 6AM-9AM',
});
}
}
// Patient king: average conversation length >= 10 exchanges
if (totalSessions > 0) {
const avgExchanges = totalActivities / totalSessions;
if (avgExchanges >= 10) {
achievements.push({
id: 'patient-king',
name: 'Patient King',
description: 'Your average conversation length is 10+ exchanges',
});
}
}
// Quick finisher: 70% of sessions have <= 2 exchanges
let quickSessions = 0;
// Since we don't have per-session exchange counts easily available,
// we'll estimate based on the distribution of activities
if (totalSessions > 0) {
// This is a simplified calculation - in a real implementation,
// we'd need to count exchanges per session
const avgPerSession = totalActivities / totalSessions;
if (avgPerSession <= 2) {
// Estimate based on low average
quickSessions = Math.floor(totalSessions * 0.7);
}
if (quickSessions / totalSessions >= 0.7) {
achievements.push({
id: 'quick-finisher',
name: 'Quick Finisher',
description: '70% of your sessions end in 2 exchanges or fewer',
});
}
}
// Explorer: for users with insufficient data or default
if (achievements.length === 0) {
achievements.push({
id: 'explorer',
name: 'Explorer',
description: 'Getting started with Qwen Code',
});
}
return achievements;
}
// Process chat files from all projects in the base directory and generate insights
async generateInsights(baseDir: string): Promise<InsightData> {
// Initialize data structures
const heatmap: HeatMapData = {};
const tokenUsage: TokenUsageData = {};
const activeHours: { [hour: number]: number } = {};
const sessionStartTimes: { [sessionId: string]: Date } = {};
const sessionEndTimes: { [sessionId: string]: Date } = {};
try {
// Get all project directories in the base directory
const projectDirs = await fs.readdir(baseDir);
// Process each project directory
for (const projectDir of projectDirs) {
const projectPath = path.join(baseDir, projectDir);
const stats = await fs.stat(projectPath);
// Only process if it's a directory
if (stats.isDirectory()) {
const chatsDir = path.join(projectPath, 'chats');
let chatFiles: string[] = [];
try {
// Get all chat files in the chats directory
const files = await fs.readdir(chatsDir);
chatFiles = files.filter((file) => file.endsWith('.jsonl'));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.log(
`Error reading chats directory for project ${projectDir}: ${error}`,
);
}
// Continue to next project if chats directory doesn't exist
continue;
}
// Process each chat file in this project
for (const file of chatFiles) {
const filePath = path.join(chatsDir, file);
const records = await readJsonlFile<ChatRecord>(filePath);
// Process each record
for (const record of records) {
const timestamp = new Date(record.timestamp);
const dateKey = this.formatDate(timestamp);
const hour = timestamp.getHours();
// Update heatmap (count of interactions per day)
heatmap[dateKey] = (heatmap[dateKey] || 0) + 1;
// Update active hours
activeHours[hour] = (activeHours[hour] || 0) + 1;
// Update token usage
if (record.usageMetadata) {
const usage = tokenUsage[dateKey] || {
input: 0,
output: 0,
total: 0,
};
usage.input += record.usageMetadata.promptTokenCount || 0;
usage.output += record.usageMetadata.candidatesTokenCount || 0;
usage.total += record.usageMetadata.totalTokenCount || 0;
tokenUsage[dateKey] = usage;
}
// Track session times
if (!sessionStartTimes[record.sessionId]) {
sessionStartTimes[record.sessionId] = timestamp;
}
sessionEndTimes[record.sessionId] = timestamp;
}
}
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// Base directory doesn't exist, return empty insights
console.log(`Base directory does not exist: ${baseDir}`);
} else {
console.log(`Error reading base directory: ${error}`);
}
}
// Calculate streak data
const streakData = this.calculateStreaks(Object.keys(heatmap));
// Calculate longest work session
let longestWorkDuration = 0;
let longestWorkDate: string | null = null;
for (const sessionId in sessionStartTimes) {
const start = sessionStartTimes[sessionId];
const end = sessionEndTimes[sessionId];
const durationMinutes = Math.round(
(end.getTime() - start.getTime()) / (1000 * 60),
);
if (durationMinutes > longestWorkDuration) {
longestWorkDuration = durationMinutes;
longestWorkDate = this.formatDate(start);
}
}
// Calculate latest active time
let latestActiveTime: string | null = null;
let latestTimestamp = new Date(0);
for (const dateStr in heatmap) {
const date = new Date(dateStr);
if (date > latestTimestamp) {
latestTimestamp = date;
latestActiveTime = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
}
// Calculate achievements
const achievements = this.calculateAchievements(
activeHours,
heatmap,
tokenUsage,
);
return {
heatmap,
tokenUsage,
currentStreak: streakData.currentStreak,
longestStreak: streakData.longestStreak,
longestWorkDate,
longestWorkDuration,
activeHours,
latestActiveTime,
achievements,
};
}
}

View File

@@ -1,57 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { DataProcessor } from './DataProcessor.js';
import { TemplateRenderer } from './TemplateRenderer.js';
import type { InsightData } from '../types/StaticInsightTypes.js';
export class StaticInsightGenerator {
private dataProcessor: DataProcessor;
private templateRenderer: TemplateRenderer;
constructor() {
this.dataProcessor = new DataProcessor();
this.templateRenderer = new TemplateRenderer();
}
// Ensure the output directory exists
private async ensureOutputDirectory(): Promise<string> {
const outputDir = path.join(os.homedir(), '.qwen', 'insights');
await fs.mkdir(outputDir, { recursive: true });
return outputDir;
}
// Generate the static insight HTML file
async generateStaticInsight(baseDir: string): Promise<string> {
try {
// Process data
console.log('Processing insight data...');
const insights: InsightData =
await this.dataProcessor.generateInsights(baseDir);
// Render HTML
console.log('Rendering HTML template...');
const html = await this.templateRenderer.renderInsightHTML(insights);
// Ensure output directory exists
const outputDir = await this.ensureOutputDirectory();
const outputPath = path.join(outputDir, 'insight.html');
// Write the HTML file
console.log(`Writing HTML file to: ${outputPath}`);
await fs.writeFile(outputPath, html, 'utf-8');
console.log('Static insight generation completed successfully');
return outputPath;
} catch (error) {
console.log(`Error generating static insight: ${error}`);
throw error;
}
}
}

View File

@@ -1,55 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs/promises';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import type { InsightData } from '../types/StaticInsightTypes.js';
export class TemplateRenderer {
private templateDir: string;
constructor() {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
this.templateDir = path.join(__dirname, '..', 'templates');
}
// Load template files
private async loadTemplate(): Promise<string> {
const templatePath = path.join(this.templateDir, 'insight-template.html');
return await fs.readFile(templatePath, 'utf-8');
}
private async loadStyles(): Promise<string> {
const stylesPath = path.join(this.templateDir, 'styles', 'base.css');
return await fs.readFile(stylesPath, 'utf-8');
}
private async loadScripts(): Promise<string> {
const scriptsPath = path.join(
this.templateDir,
'scripts',
'insight-app.js',
);
return await fs.readFile(scriptsPath, 'utf-8');
}
// Render the complete HTML file
async renderInsightHTML(insights: InsightData): Promise<string> {
const template = await this.loadTemplate();
const styles = await this.loadStyles();
const scripts = await this.loadScripts();
// Replace all placeholders
let html = template;
html = html.replace('{{STYLES_PLACEHOLDER}}', styles);
html = html.replace('{{DATA_PLACEHOLDER}}', JSON.stringify(insights));
html = html.replace('{{SCRIPTS_PLACEHOLDER}}', scripts);
return html;
}
}

View File

@@ -1,48 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwen Code Insights</title>
<style>
{{STYLES_PLACEHOLDER}}
</style>
</head>
<body>
<div class="min-h-screen" id="container">
<div class="mx-auto max-w-6xl px-6 py-10 md:py-12">
<header class="mb-8 space-y-3 text-center">
<p
class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500"
>
Insights
</p>
<h1 class="text-3xl font-semibold text-slate-900 md:text-4xl">
Qwen Code Insights
</h1>
<p class="text-sm text-slate-600">
Your personalized coding journey and patterns
</p>
</header>
<!-- React App Mount Point -->
<div id="react-root"></div>
</div>
</div>
<!-- React CDN -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- CDN Libraries -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<!-- Application Data -->
<script type="text/babel">
window.INSIGHT_DATA = {{DATA_PLACEHOLDER}};
{{SCRIPTS_PLACEHOLDER}}
</script>
</body>
</html>

View File

@@ -1,510 +0,0 @@
/* eslint-disable react/prop-types */
/* eslint-disable no-undef */
// React-based implementation of the insight app
// Converts the vanilla JavaScript implementation to React
const { useState, useRef, useEffect } = React;
// Main App Component
function InsightApp({ data }) {
if (!data) {
return (
<div className="text-center text-slate-600">
No insight data available
</div>
);
}
return (
<div>
<DashboardCards insights={data} />
<HeatmapSection heatmap={data.heatmap} />
<TokenUsageSection tokenUsage={data.tokenUsage} />
<AchievementsSection achievements={data.achievements} />
<ExportButton />
</div>
);
}
// Dashboard Cards Component
function DashboardCards({ insights }) {
const cardClass = 'glass-card p-6';
const sectionTitleClass =
'text-lg font-semibold tracking-tight text-slate-900';
const captionClass = 'text-sm font-medium text-slate-500';
return (
<div className="grid gap-4 md:grid-cols-3 md:gap-6">
<StreakCard
currentStreak={insights.currentStreak}
longestStreak={insights.longestStreak}
cardClass={cardClass}
captionClass={captionClass}
/>
<ActiveHoursChart
activeHours={insights.activeHours}
cardClass={cardClass}
sectionTitleClass={sectionTitleClass}
/>
<WorkSessionCard
longestWorkDuration={insights.longestWorkDuration}
longestWorkDate={insights.longestWorkDate}
latestActiveTime={insights.latestActiveTime}
cardClass={cardClass}
sectionTitleClass={sectionTitleClass}
/>
</div>
);
}
// Streak Card Component
function StreakCard({ currentStreak, longestStreak, cardClass, captionClass }) {
return (
<div className={`${cardClass} h-full`}>
<div className="flex items-start justify-between">
<div>
<p className={captionClass}>Current Streak</p>
<p className="mt-1 text-4xl font-bold text-slate-900">
{currentStreak}
<span className="ml-2 text-base font-semibold text-slate-500">
days
</span>
</p>
</div>
<span className="rounded-full bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700">
Longest {longestStreak}d
</span>
</div>
</div>
);
}
// Active Hours Chart Component
function ActiveHoursChart({ activeHours, cardClass, sectionTitleClass }) {
const chartRef = useRef(null);
const chartInstance = useRef(null);
useEffect(() => {
if (chartInstance.current) {
chartInstance.current.destroy();
}
const canvas = chartRef.current;
if (!canvas || !window.Chart) return;
const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`);
const data = labels.map((_, i) => activeHours[i] || 0);
const ctx = canvas.getContext('2d');
if (!ctx) return;
chartInstance.current = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Activity per Hour',
data,
backgroundColor: 'rgba(52, 152, 219, 0.7)',
borderColor: 'rgba(52, 152, 219, 1)',
borderWidth: 1,
},
],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
},
},
plugins: {
legend: {
display: false,
},
},
},
});
return () => {
if (chartInstance.current) {
chartInstance.current.destroy();
}
};
}, [activeHours]);
return (
<div className={`${cardClass} h-full`}>
<div className="flex items-center justify-between">
<h3 className={sectionTitleClass}>Active Hours</h3>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
24h
</span>
</div>
<div className="mt-4 h-56 w-full">
<canvas ref={chartRef} className="w-full h-56" />
</div>
</div>
);
}
// Work Session Card Component
function WorkSessionCard({
longestWorkDuration,
longestWorkDate,
latestActiveTime,
cardClass,
sectionTitleClass,
}) {
return (
<div className={`${cardClass} h-full space-y-3`}>
<h3 className={sectionTitleClass}>Work Session</h3>
<div className="grid grid-cols-2 gap-3 text-sm text-slate-700">
<div className="rounded-xl bg-slate-50 px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Longest
</p>
<p className="mt-1 text-lg font-semibold text-slate-900">
{longestWorkDuration}m
</p>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Date
</p>
<p className="mt-1 text-lg font-semibold text-slate-900">
{longestWorkDate || '-'}
</p>
</div>
<div className="col-span-2 rounded-xl bg-slate-50 px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Last Active
</p>
<p className="mt-1 text-lg font-semibold text-slate-900">
{latestActiveTime || '-'}
</p>
</div>
</div>
</div>
);
}
// Heatmap Section Component
function HeatmapSection({ heatmap }) {
const cardClass = 'glass-card p-6';
const sectionTitleClass =
'text-lg font-semibold tracking-tight text-slate-900';
return (
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
<div className="flex items-center justify-between">
<h3 className={sectionTitleClass}>Activity Heatmap</h3>
<span className="text-xs font-semibold text-slate-500">Past year</span>
</div>
<div className="heatmap-container">
<div className="min-w-[720px] rounded-xl border border-slate-100 bg-white/70 p-4 shadow-inner shadow-slate-100">
<ActivityHeatmap heatmapData={heatmap} />
</div>
</div>
</div>
);
}
// Activity Heatmap Component
function ActivityHeatmap({ heatmapData }) {
const width = 1000;
const height = 150;
const cellSize = 14;
const cellPadding = 2;
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
// Generate all dates for the past year
const dates = [];
const currentDate = new Date(oneYearAgo);
while (currentDate <= today) {
dates.push(new Date(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
}
const colorLevels = [0, 2, 4, 10, 20];
const colors = ['#e2e8f0', '#a5d8ff', '#74c0fc', '#339af0', '#1c7ed6'];
function getColor(value) {
if (value === 0) return colors[0];
for (let i = colorLevels.length - 1; i >= 1; i--) {
if (value >= colorLevels[i]) return colors[i];
}
return colors[1];
}
const weeksInYear = Math.ceil(dates.length / 7);
const startX = 50;
const startY = 20;
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
// Generate month labels
const monthLabels = [];
let currentMonth = oneYearAgo.getMonth();
let monthX = startX;
for (let week = 0; week < weeksInYear; week++) {
const weekDate = new Date(oneYearAgo);
weekDate.setDate(weekDate.getDate() + week * 7);
if (weekDate.getMonth() !== currentMonth) {
currentMonth = weekDate.getMonth();
monthLabels.push({
x: monthX,
text: months[currentMonth],
});
monthX = startX + week * (cellSize + cellPadding);
}
}
return (
<svg
className="heatmap-svg"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
>
{/* Render heatmap cells */}
{dates.map((date, index) => {
const week = Math.floor(index / 7);
const day = index % 7;
const x = startX + week * (cellSize + cellPadding);
const y = startY + day * (cellSize + cellPadding);
const dateKey = date.toISOString().split('T')[0];
const value = heatmapData[dateKey] || 0;
const color = getColor(value);
return (
<rect
key={dateKey}
className="heatmap-day"
x={x}
y={y}
width={cellSize}
height={cellSize}
rx="2"
fill={color}
data-date={dateKey}
data-count={value}
>
<title>
{dateKey}: {value} activities
</title>
</rect>
);
})}
{/* Render month labels */}
{monthLabels.map((label, index) => (
<text key={index} x={label.x} y="15" fontSize="12" fill="#64748b">
{label.text}
</text>
))}
{/* Render legend */}
<text x={startX} y={height - 40} fontSize="12" fill="#64748b">
Less
</text>
{colors.map((color, index) => {
const legendX = startX + 40 + index * (cellSize + 2);
return (
<rect
key={index}
x={legendX}
y={height - 30}
width="10"
height="10"
rx="2"
fill={color}
/>
);
})}
<text
x={startX + 40 + colors.length * (cellSize + 2) + 5}
y={height - 21}
fontSize="12"
fill="#64748b"
>
More
</text>
</svg>
);
}
// Token Usage Section Component
function TokenUsageSection({ tokenUsage }) {
const cardClass = 'glass-card p-6';
const sectionTitleClass =
'text-lg font-semibold tracking-tight text-slate-900';
function calculateTotalTokens(tokenUsage, type) {
return Object.values(tokenUsage).reduce(
(acc, usage) => acc + usage[type],
0,
);
}
return (
<div className={`${cardClass} mt-4 md:mt-6`}>
<div className="space-y-3">
<h3 className={sectionTitleClass}>Token Usage</h3>
<div className="grid grid-cols-3 gap-3">
<TokenUsageCard
label="Input"
value={calculateTotalTokens(tokenUsage, 'input').toLocaleString()}
/>
<TokenUsageCard
label="Output"
value={calculateTotalTokens(tokenUsage, 'output').toLocaleString()}
/>
<TokenUsageCard
label="Total"
value={calculateTotalTokens(tokenUsage, 'total').toLocaleString()}
/>
</div>
</div>
</div>
);
}
// Token Usage Card Component
function TokenUsageCard({ label, value }) {
return (
<div className="rounded-xl bg-slate-50 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
</div>
);
}
// Achievements Section Component
function AchievementsSection({ achievements }) {
const cardClass = 'glass-card p-6';
const sectionTitleClass =
'text-lg font-semibold tracking-tight text-slate-900';
return (
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
<div className="flex items-center justify-between">
<h3 className={sectionTitleClass}>Achievements</h3>
<span className="text-xs font-semibold text-slate-500">
{achievements.length} total
</span>
</div>
{achievements.length === 0 ? (
<p className="text-sm text-slate-600">
No achievements yet. Keep coding!
</p>
) : (
<div className="divide-y divide-slate-200">
{achievements.map((achievement, index) => (
<AchievementItem key={index} achievement={achievement} />
))}
</div>
)}
</div>
);
}
// Achievement Item Component
function AchievementItem({ achievement }) {
return (
<div className="flex flex-col gap-1 py-3 text-left">
<span className="text-base font-semibold text-slate-900">
{achievement.name}
</span>
<p className="text-sm text-slate-600">{achievement.description}</p>
</div>
);
}
// Export Button Component
function ExportButton() {
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => {
const container = document.getElementById('container');
if (!container || !window.html2canvas) {
alert('Export functionality is not available.');
return;
}
setIsExporting(true);
try {
const canvas = await html2canvas(container, {
scale: 2,
useCORS: true,
logging: false,
});
const imgData = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = imgData;
link.download = `qwen-insights-${new Date().toISOString().slice(0, 10)}.png`;
link.click();
} catch (error) {
console.error('Export error:', error);
alert('Failed to export image. Please try again.');
} finally {
setIsExporting(false);
}
};
return (
<div className="mt-6 flex justify-center">
<button
onClick={handleExport}
disabled={isExporting}
className="group inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-white shadow-soft transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400 hover:-translate-y-[1px] hover:shadow-lg active:translate-y-[1px] disabled:opacity-50"
>
{isExporting ? 'Exporting...' : 'Export as Image'}
<span className="text-slate-200 transition group-hover:translate-x-0.5">
</span>
</button>
</div>
);
}
// App Initialization - Mount React app when DOM is ready
const container = document.getElementById('react-root');
if (container && window.INSIGHT_DATA && window.ReactDOM) {
const root = ReactDOM.createRoot(container);
root.render(React.createElement(InsightApp, { data: window.INSIGHT_DATA }));
} else {
console.error('Failed to mount React app:', {
container: !!container,
data: !!window.INSIGHT_DATA,
ReactDOM: !!window.ReactDOM,
});
}

View File

@@ -1,610 +0,0 @@
/* Tailwind CSS Base Styles extracted from index-CV6J1oXz.css */
*,
:before,
:after,
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: #3b82f680;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
*,
:before,
:after {
box-sizing: border-box;
border: 0 solid #e5e7eb;
}
:before,
:after {
--tw-content: "";
}
html,
:host {
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-feature-settings: normal;
font-variation-settings: normal;
-webkit-tap-highlight-color: transparent;
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
line-height: 1.5;
}
body {
line-height: inherit;
margin: 0;
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
--tw-gradient-from: #f8fafc var(--tw-gradient-from-position);
--tw-gradient-to: #f1f5f9 var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), #fff var(--tw-gradient-via-position), var(--tw-gradient-to);
--tw-text-opacity: 1;
min-height: 100vh;
color: rgb(15 23 42 / var(--tw-text-opacity, 1));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Glass Card Effect */
.glass-card {
--tw-border-opacity: 1;
border-width: 1px;
border-color: rgb(226 232 240 / var(--tw-border-opacity, 1));
--tw-shadow: 0 10px 40px #0f172a14;
--tw-shadow-colored: 0 10px 40px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
--tw-backdrop-blur: blur(8px);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
background-color: #ffffff99;
border-radius: 1rem;
}
/* Utility Classes */
.col-span-2 {
grid-column: span 2 / span 2;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.mb-8 {
margin-bottom: 2rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.mt-6 {
margin-top: 1.5rem;
}
.block {
display: block;
}
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.grid {
display: grid;
}
.h-56 {
height: 14rem;
}
.h-full {
height: 100%;
}
.min-h-screen {
min-height: 100vh;
}
.w-full {
width: 100%;
}
.min-w-\[720px\] {
min-width: 720px;
}
.max-w-6xl {
max-width: 72rem;
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.gap-4 {
gap: 1rem;
}
.space-y-3> :not([hidden])~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
}
.space-y-4> :not([hidden])~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
}
.divide-y> :not([hidden])~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
}
.divide-slate-200> :not([hidden])~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(226 232 240 / var(--tw-divide-opacity, 1));
}
.overflow-x-auto {
overflow-x: auto;
}
.rounded-full {
border-radius: 9999px;
}
.rounded-xl {
border-radius: 1.25rem;
}
.border {
border-width: 1px;
}
.border-slate-100 {
--tw-border-opacity: 1;
border-color: rgb(241 245 249 / var(--tw-border-opacity, 1));
}
.bg-emerald-50 {
--tw-bg-opacity: 1;
background-color: rgb(236 253 245 / var(--tw-bg-opacity, 1));
}
.bg-slate-100 {
--tw-bg-opacity: 1;
background-color: rgb(241 245 249 / var(--tw-bg-opacity, 1));
}
.bg-slate-50 {
--tw-bg-opacity: 1;
background-color: rgb(248 250 252 / var(--tw-bg-opacity, 1));
}
.bg-slate-900 {
--tw-bg-opacity: 1;
background-color: rgb(15 23 42 / var(--tw-bg-opacity, 1));
}
.bg-white\/70 {
background-color: #ffffff73;
}
.bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
}
.from-slate-50 {
--tw-gradient-from: #f8fafc var(--tw-gradient-from-position);
--tw-gradient-to: #f8fafc00 var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.via-white {
--tw-gradient-to: #ffffff00 var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), #ffffff var(--tw-gradient-via-position), var(--tw-gradient-to);
}
.to-slate-100 {
--tw-gradient-to: #f1f5f9 var(--tw-gradient-to-position);
}
.p-4 {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.px-5 {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.px-8 {
padding-left: 2rem;
padding-right: 2rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.py-10 {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.py-6 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.font-bold {
font-weight: 700;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.tracking-\[0\.2em\] {
letter-spacing: 0.2em;
}
.tracking-tight {
letter-spacing: -0.025em;
}
.tracking-wide {
letter-spacing: 0.025em;
}
.text-emerald-700 {
--tw-text-opacity: 1;
color: rgb(4 120 87 / var(--tw-text-opacity, 1));
}
.text-rose-700 {
--tw-text-opacity: 1;
color: rgb(190 18 60 / var(--tw-text-opacity, 1));
}
.text-slate-200 {
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
}
.text-slate-400 {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
}
.text-slate-500 {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity, 1));
}
.text-slate-600 {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity, 1));
}
.text-slate-700 {
--tw-text-opacity: 1;
color: rgb(51 65 85 / var(--tw-text-opacity, 1));
}
.text-slate-900 {
--tw-text-opacity: 1;
color: rgb(15 23 42 / var(--tw-text-opacity, 1));
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.shadow-inner {
--tw-shadow: inset 0 2px 4px 0 #0000000d;
--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-soft {
--tw-shadow: 0 10px 40px #0f172a14;
--tw-shadow-colored: 0 10px 40px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-slate-100 {
--tw-shadow-color: #f1f5f9;
--tw-shadow: var(--tw-shadow-colored);
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-duration: 0.15s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.hover\:-translate-y-\[1px\]:hover {
--tw-translate-y: -1px;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.hover\:shadow-lg:hover {
--tw-shadow: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.focus-visible\:outline:focus-visible {
outline-style: solid;
}
.focus-visible\:outline-2:focus-visible {
outline-width: 2px;
}
.focus-visible\:outline-offset-2:focus-visible {
outline-offset: 2px;
}
.focus-visible\:outline-slate-400:focus-visible {
outline-color: #94a3b8;
}
.active\:translate-y-\[1px\]:active {
--tw-translate-y: 1px;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group:hover .group-hover\:translate-x-0\.5 {
--tw-translate-x: 0.125rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
@media (min-width: 768px) {
.md\:mt-6 {
margin-top: 1.5rem;
}
.md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.md\:gap-6 {
gap: 1.5rem;
}
.md\:py-12 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.md\:text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
}
/* Heat map specific styles */
.heatmap-container {
width: 100%;
overflow-x: auto;
}
.heatmap-svg {
min-width: 720px;
}
.heatmap-day {
cursor: pointer;
}
.heatmap-day:hover {
stroke: #00000024;
stroke-width: 1px;
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
margin-top: 8px;
}
.heatmap-legend-item {
width: 10px;
height: 10px;
border-radius: 2px;
}

View File

@@ -1,51 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
export interface UsageMetadata {
input: number;
output: number;
total: number;
}
export interface HeatMapData {
[date: string]: number;
}
export interface TokenUsageData {
[date: string]: UsageMetadata;
}
export interface AchievementData {
id: string;
name: string;
description: string;
}
export interface InsightData {
heatmap: HeatMapData;
tokenUsage: TokenUsageData;
currentStreak: number;
longestStreak: number;
longestWorkDate: string | null;
longestWorkDuration: number; // in minutes
activeHours: { [hour: number]: number };
latestActiveTime: string | null;
achievements: AchievementData[];
}
export interface StreakData {
currentStreak: number;
longestStreak: number;
dates: string[];
}
export interface StaticInsightTemplateData {
styles: string;
content: string;
data: InsightData;
scripts: string;
generatedTime: string;
}

View File

@@ -1326,6 +1326,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
@@ -1571,6 +1572,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
}),
[
@@ -1611,6 +1613,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
],
);

View File

@@ -5,19 +5,21 @@ import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
const FEEDBACK_OPTIONS = {
export const FEEDBACK_OPTIONS = {
GOOD: 1,
BAD: 2,
NOT_SURE: 3,
FINE: 3,
DISMISS: 0,
} as const;
const FEEDBACK_OPTION_KEYS = {
[FEEDBACK_OPTIONS.GOOD]: '1',
[FEEDBACK_OPTIONS.BAD]: '2',
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
[FEEDBACK_OPTIONS.FINE]: '3',
[FEEDBACK_OPTIONS.DISMISS]: '0',
} as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2', '3', '0'] as const;
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
@@ -25,15 +27,19 @@ export const FeedbackDialog: React.FC = () => {
useKeypress(
(key) => {
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
// Handle keys 0-3: permanent close with feedback/dismiss
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.FINE);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.DISMISS);
} else {
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
// Handle other keys: temporary close
uiActions.temporaryCloseFeedbackDialog();
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
@@ -53,8 +59,16 @@ export const FeedbackDialog: React.FC = () => {
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">{t('Any other key')}: </Text>
<Text>{t('Not Sure Yet')}</Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]}:{' '}
</Text>
<Text>{t('Fine')}</Text>
<Text> </Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]}:{' '}
</Text>
<Text>{t('Dismiss')}</Text>
<Text> </Text>
</Box>
</Box>
);

View File

@@ -1,130 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandContext, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { t } from '../../i18n/index.js';
import { join } from 'path';
import os from 'os';
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
// Open file in default browser
async function openFileInBrowser(filePath: string): Promise<void> {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
// Convert to file:// URL for cross-platform compatibility
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
try {
switch (process.platform) {
case 'darwin': // macOS
await execAsync(`open "${fileUrl}"`);
break;
case 'win32': // Windows
await execAsync(`start "" "${fileUrl}"`);
break;
default: // Linux and others
await execAsync(`xdg-open "${fileUrl}"`);
}
} catch (_error) {
// If opening fails, try with local file path
switch (process.platform) {
case 'darwin': // macOS
await execAsync(`open "${filePath}"`);
break;
case 'win32': // Windows
await execAsync(`start "" "${filePath}"`);
break;
default: // Linux and others
await execAsync(`xdg-open "${filePath}"`);
}
}
}
export const insightCommand: SlashCommand = {
name: 'insight',
get description() {
return t(
'generate personalized programming insights from your chat history',
);
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
try {
context.ui.setDebugMessage(t('Generating insights...'));
const projectsDir = join(os.homedir(), '.qwen', 'projects');
const insightGenerator = new StaticInsightGenerator();
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Processing your chat history...'),
},
Date.now(),
);
// Generate the static insight HTML file
const outputPath =
await insightGenerator.generateStaticInsight(projectsDir);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Insight report generated successfully!'),
},
Date.now(),
);
// Open the file in the default browser
try {
await openFileInBrowser(outputPath);
context.ui.addItem(
{
type: MessageType.INFO,
text: t('Opening insights in your browser: {{path}}', {
path: outputPath,
}),
},
Date.now(),
);
} catch (browserError) {
console.error('Failed to open browser automatically:', browserError);
context.ui.addItem(
{
type: MessageType.INFO,
text: t(
'Insights generated at: {{path}}. Please open this file in your browser.',
{
path: outputPath,
},
),
},
Date.now(),
);
}
context.ui.setDebugMessage(t('Insights ready.'));
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to generate insights: {{error}}', {
error: (error as Error).message,
}),
},
Date.now(),
);
console.error('Insight generation error:', error);
}
},
};

View File

@@ -36,6 +36,11 @@ vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
temporaryCloseFeedbackDialog: vi.fn(),
})),
}));
const mockSlashCommands: SlashCommand[] = [
{

View File

@@ -37,6 +37,7 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps {
buffer: TextBuffer;
@@ -109,6 +110,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const uiActions = useUIActions();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -337,12 +339,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
// Continue processing the key for normal input handling
}
}
// Reset ESC count and hide prompt on any non-ESC key
@@ -712,6 +718,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts,
showShortcuts,
uiState,
uiActions,
],
);

View File

@@ -71,6 +71,7 @@ export interface UIActions {
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
temporaryCloseFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}

View File

@@ -15,6 +15,7 @@ import {
USER_SETTINGS_PATH,
} from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import { FEEDBACK_OPTIONS } from '../FeedbackDialog.js';
import stripJsonComments from 'strip-json-comments';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
@@ -96,37 +97,48 @@ export const useFeedbackDialog = ({
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [isFeedbackDismissedTemporarily, setIsFeedbackDismissedTemporarily] =
useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
// Record the timestamp when feedback dialog is shown (fire and forget)
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
}, [settings]);
}, []);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const temporaryCloseFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(false);
setIsFeedbackDismissedTemporarily(true);
}, []);
const submitFeedback = useCallback(
(rating: number) => {
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
// Only create and log feedback event for ratings 1-3 (GOOD, BAD, FINE)
// Rating 0 (DISMISS) should not trigger any telemetry
if (rating >= FEEDBACK_OPTIONS.GOOD && rating <= FEEDBACK_OPTIONS.FINE) {
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
}
// Record the timestamp when feedback dialog is submitted
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, closeFeedbackDialog],
[closeFeedbackDialog, sessionStats.sessionId, config, settings],
);
useEffect(() => {
@@ -140,13 +152,15 @@ export const useFeedbackDialog = ({
// 5. Random chance (25% probability)
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
// 8. Not temporarily dismissed
if (
config.getAuthType() !== AuthType.QWEN_OAUTH ||
!config.getUsageStatisticsEnabled() ||
settings.merged.ui?.enableUserFeedback === false ||
!lastMessageIsAIResponse(history) ||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
!meetsMinimumSessionRequirements(sessionStats)
!meetsMinimumSessionRequirements(sessionStats) ||
isFeedbackDismissedTemporarily
) {
return;
}
@@ -164,15 +178,27 @@ export const useFeedbackDialog = ({
history,
sessionStats,
isFeedbackDialogOpen,
isFeedbackDismissedTemporarily,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
// Reset temporary dismissal when a new AI response starts streaming
useEffect(() => {
if (
streamingState === StreamingState.Responding &&
isFeedbackDismissedTemporarily
) {
setIsFeedbackDismissedTemporarily(false);
}
}, [streamingState, isFeedbackDismissedTemporarily]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
};
};

View File

@@ -78,7 +78,6 @@ export * from './utils/promptIdContext.js';
export * from './utils/thoughtUtils.js';
export * from './utils/toml-to-markdown-converter.js';
export * from './utils/yaml-parser.js';
export * from './utils/jsonl-utils.js';
// Config resolution utilities
export * from './utils/configResolver.js';

View File

@@ -49,10 +49,7 @@ function copyFilesRecursive(source, target, rootSourceDir) {
const normalizedPath = relativePath.replace(/\\/g, '/');
const isLocaleJs =
ext === '.js' && normalizedPath.startsWith('i18n/locales/');
const isInsightTemplate = normalizedPath.startsWith(
'services/insight/templates/',
);
if (extensionsToCopy.includes(ext) || isLocaleJs || isInsightTemplate) {
if (extensionsToCopy.includes(ext) || isLocaleJs) {
fs.copyFileSync(sourcePath, targetPath);
}
}