hydro-ai-helper 2.0.7 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,30 @@
1
1
  import React from 'react';
2
2
  import { i18n } from '@hydrooj/ui-default';
3
- import {
4
- COLORS, SPACING, RADIUS, SHADOWS, TRANSITIONS, FONT_FAMILY,
5
- getButtonStyle, getPillStyle, getInputStyle,
6
- } from '../utils/styles';
3
+ import { TypeIcon, SendIcon, AttachIcon, RefreshIcon, RemoveIcon } from './icons';
4
+
5
+ // ── 方案 A · 克制蓝 调色 ───────────────────────────────────────────────
6
+ const A = {
7
+ border: '#eef1f5',
8
+ cardBorder: '#e3e8ef',
9
+ inputBg: '#f8fafc',
10
+ primary: '#2563eb',
11
+ gradient: 'linear-gradient(135deg, #2563eb, #5b8def)',
12
+ textPrimary: '#1e2536',
13
+ textSecondary: '#475569',
14
+ textMuted: '#64748b',
15
+ textFaint: '#94a3b8',
16
+ placeholder: '#aab4c2',
17
+ selBorder: '#2563eb',
18
+ selBg: '#f3f7ff',
19
+ idleBorder: '#eef1f5',
20
+ idleBg: '#fafbfd',
21
+ iconIdleBg: '#eef2f8',
22
+ success: '#10b981',
23
+ warning: '#f59e0b',
24
+ error: '#ef4444',
25
+ disabledBg: '#f1f5f9',
26
+ disabledText: '#cbd5e1',
27
+ };
7
28
 
8
29
  interface QuestionType {
9
30
  value: string;
@@ -42,12 +63,49 @@ export const ChatInput: React.FC<ChatInputProps> = ({
42
63
  const isFollowUp = conversationHistoryLength > 0;
43
64
  const canSubmit = isFirstConversation ? !!questionType : !!userThinking.trim();
44
65
 
66
+ // ── 问题类型卡片(2×2 网格,替换原 pill 行)─────────────────────────
67
+ const renderTypeCard = (type: QuestionType) => {
68
+ const isSelected = questionType === type.value;
69
+ return (
70
+ <label
71
+ key={type.value}
72
+ className="ai-type-card"
73
+ style={{
74
+ display: 'block', padding: '11px 12px', borderRadius: '12px', cursor: 'pointer',
75
+ border: `1.5px solid ${isSelected ? A.selBorder : A.idleBorder}`,
76
+ background: isSelected ? A.selBg : A.idleBg,
77
+ transition: 'all 160ms cubic-bezier(.4,0,.2,1)', userSelect: 'none',
78
+ }}
79
+ >
80
+ <input
81
+ type="radio" name="questionType" value={type.value}
82
+ checked={isSelected} onChange={(e) => onQuestionTypeChange(e.target.value)}
83
+ style={{ display: 'none' }}
84
+ />
85
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '5px' }}>
86
+ <div style={{
87
+ width: '24px', height: '24px', borderRadius: '7px',
88
+ background: isSelected ? A.primary : A.iconIdleBg,
89
+ color: isSelected ? '#fff' : A.textMuted,
90
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
91
+ }}>
92
+ <TypeIcon type={type.value} size={14} />
93
+ </div>
94
+ <span style={{ fontSize: '13px', fontWeight: 600, color: A.textPrimary }}>{i18n(type.label)}</span>
95
+ </div>
96
+ {type.description && (
97
+ <div style={{ fontSize: '11px', color: A.textMuted, lineHeight: 1.4 }}>{i18n(type.description)}</div>
98
+ )}
99
+ </label>
100
+ );
101
+ };
102
+
45
103
  const renderIncludeCodeCheckbox = (labelText: string) => (
46
104
  <label
47
105
  style={{
48
106
  display: 'flex', alignItems: 'center',
49
107
  cursor: questionType === 'optimize' ? 'not-allowed' : 'pointer',
50
- fontSize: '12px', color: questionType === 'optimize' ? COLORS.textDisabled : COLORS.textSecondary,
108
+ fontSize: '11.5px', color: questionType === 'optimize' ? A.disabledText : A.textMuted,
51
109
  whiteSpace: 'nowrap', alignSelf: 'center',
52
110
  }}
53
111
  title={questionType === 'optimize' ? i18n('ai_helper_student_optimize_code_required') : undefined}
@@ -55,11 +113,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
55
113
  <input
56
114
  type="checkbox" checked={includeCode} disabled={questionType === 'optimize'}
57
115
  onChange={(e) => onIncludeCodeChange(e.target.checked)}
58
- style={{ marginRight: '6px', accentColor: COLORS.primary }}
116
+ style={{ marginRight: '6px', accentColor: A.primary }}
59
117
  />
60
118
  {labelText}
61
- {questionType === 'optimize' && <span style={{ marginLeft: '4px', color: COLORS.warning, fontSize: '11px' }}>({i18n('ai_helper_student_required')})</span>}
62
- {includeCode && code && questionType !== 'optimize' && <span style={{ marginLeft: '4px', color: COLORS.success, fontSize: '11px' }}>&#10003;</span>}
119
+ {questionType === 'optimize' && <span style={{ marginLeft: '4px', color: A.warning, fontSize: '11px' }}>({i18n('ai_helper_student_required')})</span>}
120
+ {includeCode && code && questionType !== 'optimize' && <span style={{ marginLeft: '4px', color: A.success, fontSize: '11px' }}>&#10003;</span>}
63
121
  </label>
64
122
  );
65
123
 
@@ -69,14 +127,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
69
127
  onChange={(e) => onUserThinkingChange(e.target.value)}
70
128
  placeholder={isFirstConversation ? i18n('ai_helper_student_placeholder_first') : i18n('ai_helper_student_placeholder_followup')}
71
129
  style={{
72
- ...getInputStyle(),
73
- border: 'none', outline: 'none', boxShadow: 'none',
74
- backgroundColor: 'transparent',
75
- flex: 1, minHeight, maxHeight,
76
- resize: 'none' as const, boxSizing: 'border-box' as const,
130
+ width: '100%', border: 'none', outline: 'none', boxShadow: 'none',
131
+ background: 'transparent', color: A.textPrimary,
132
+ fontSize: '12.5px', lineHeight: 1.6, fontFamily: 'inherit',
133
+ flex: 1, minHeight, maxHeight, resize: 'none', boxSizing: 'border-box', padding: 0,
77
134
  }}
78
- onFocus={() => {}}
79
- onBlur={() => {}}
80
135
  onKeyDown={(e) => {
81
136
  if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); }
82
137
  }}
@@ -85,137 +140,84 @@ export const ChatInput: React.FC<ChatInputProps> = ({
85
140
 
86
141
  const disabledSubmit = isLoading || !canSubmit;
87
142
  const submitStyle: React.CSSProperties = {
88
- width: '36px',
89
- height: '36px',
90
- borderRadius: '50%',
91
- background: disabledSubmit ? COLORS.bgDisabled : COLORS.gradient,
92
- color: disabledSubmit ? COLORS.textDisabled : '#ffffff',
93
- border: 'none',
94
- display: 'flex',
95
- alignItems: 'center',
96
- justifyContent: 'center',
97
- cursor: disabledSubmit ? 'not-allowed' : 'pointer',
98
- transition: 'all 150ms ease',
143
+ width: '32px', height: '32px', borderRadius: '50%',
144
+ background: disabledSubmit ? A.disabledBg : A.gradient,
145
+ color: disabledSubmit ? A.disabledText : '#ffffff',
146
+ border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
147
+ cursor: disabledSubmit ? 'not-allowed' : 'pointer', transition: 'all 150ms ease',
99
148
  boxShadow: disabledSubmit ? 'none' : '0 2px 6px rgba(37, 99, 235, 0.3)',
100
- flexShrink: 0,
101
- fontSize: '16px',
102
- padding: 0,
149
+ flexShrink: 0, padding: 0,
150
+ };
151
+
152
+ const pillButton: React.CSSProperties = {
153
+ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11.5px',
154
+ color: A.textSecondary, padding: '5px 11px', border: `1px solid ${A.cardBorder}`,
155
+ borderRadius: '999px', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
103
156
  };
104
157
 
105
158
  return (
106
- <div style={{ background: COLORS.bgPage, flexShrink: 0 }}>
159
+ <div style={{ background: '#fff', flexShrink: 0 }}>
160
+ <style>{`.ai-type-card:hover{border-color:#c5d4f5 !important}.ai-input-card:focus-within{border-color:#2563eb !important;box-shadow:0 0 0 3px rgba(37,99,235,.18)}`}</style>
107
161
  {errorBanner}
108
162
 
109
- {/* Question type pills - above the card */}
163
+ {/* 问题类型卡片 首次对话 */}
110
164
  {isFirstConversation && (
111
- <div style={{ padding: `${SPACING.sm} ${SPACING.base} 0` }}>
112
- <div style={{ fontSize: '12px', color: COLORS.textSecondary, marginBottom: SPACING.xs }}>{i18n('ai_helper_student_select_type')}:</div>
113
- <div className="hide-scrollbar" style={{ display: 'flex', overflowX: 'auto', gap: SPACING.sm }}>
114
- {questionTypes.map(type => {
115
- const isSelected = questionType === type.value;
116
- return (
117
- <label
118
- key={type.value}
119
- style={{
120
- ...getPillStyle(isSelected),
121
- userSelect: 'none' as const,
122
- }}
123
- >
124
- <input
125
- type="radio" name="questionType" value={type.value}
126
- checked={isSelected} onChange={(e) => onQuestionTypeChange(e.target.value)}
127
- style={{ display: 'none' }}
128
- />
129
- {i18n(type.label)}
130
- </label>
131
- );
132
- })}
165
+ <div style={{ padding: '12px 16px 0' }}>
166
+ <div style={{ fontSize: '12px', color: A.textFaint, marginBottom: '8px' }}>{i18n('ai_helper_student_select_type')}</div>
167
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
168
+ {questionTypes.map(renderTypeCard)}
133
169
  </div>
134
- {questionType && (() => {
135
- const selected = questionTypes.find(t => t.value === questionType);
136
- const descKey = (selected as any)?.description;
137
- return descKey ? (
138
- <div style={{ fontSize: '12px', color: COLORS.textSecondary, marginTop: SPACING.xs }}>
139
- {i18n(descKey)}
140
- {questionType === 'debug' && i18n('ai_helper_student_debug_auto_attach')}
141
- </div>
142
- ) : null;
143
- })()}
170
+ {questionType === 'debug' && (
171
+ <div style={{ fontSize: '11.5px', color: A.textMuted, marginTop: '8px' }}>
172
+ {i18n('ai_helper_student_debug_auto_attach')}
173
+ </div>
174
+ )}
144
175
  </div>
145
176
  )}
146
177
 
147
- {/* Follow-up action buttons - above the card */}
178
+ {/* 追问操作 后续对话 */}
148
179
  {isFollowUp && (
149
- <div style={{ display: 'flex', gap: SPACING.sm, padding: `${SPACING.sm} ${SPACING.base} 0` }}>
150
- <button
151
- type="button" onClick={onRefreshCode}
152
- style={{
153
- ...getButtonStyle('secondary'),
154
- fontSize: '12px', padding: `${SPACING.xs} ${SPACING.md}`,
155
- display: 'flex', alignItems: 'center', gap: SPACING.xs,
156
- }}
157
- >
158
- 📎 {includeCode ? i18n('ai_helper_student_code_attached') : i18n('ai_helper_student_attach_code')}
180
+ <div style={{ display: 'flex', gap: '8px', padding: '10px 16px 0' }}>
181
+ <button type="button" onClick={onRefreshCode} style={pillButton}>
182
+ <AttachIcon size={12} /> {includeCode ? i18n('ai_helper_student_code_attached') : i18n('ai_helper_student_attach_code')}
159
183
  </button>
160
- <button
161
- type="button" onClick={onNewConversation}
162
- style={{
163
- ...getButtonStyle('ghost'),
164
- fontSize: '12px', padding: `${SPACING.xs} ${SPACING.md}`,
165
- border: `1px solid ${COLORS.border}`,
166
- }}
167
- >
168
- 🔄 {i18n('ai_helper_student_new_conversation')}
184
+ <button type="button" onClick={onNewConversation} style={pillButton}>
185
+ <RefreshIcon size={12} /> {i18n('ai_helper_student_new_conversation')}
169
186
  </button>
170
187
  </div>
171
188
  )}
172
189
 
190
+ {/* 已附带代码预览 */}
173
191
  {isFollowUp && includeCode && code && (
174
- <div style={{
175
- background: COLORS.bgPage, border: `1px solid ${COLORS.border}`, borderRadius: RADIUS.md,
176
- padding: SPACING.sm, margin: `${SPACING.sm} ${SPACING.base} 0`, fontSize: '11px',
177
- }}>
178
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.xs }}>
179
- <span style={{ color: COLORS.textSecondary }}>📝 {i18n('ai_helper_student_code_attached')} ({code.length} {i18n('ai_helper_student_chars')})</span>
180
- <button
181
- type="button" onClick={onCodeClear}
182
- style={{ background: 'none', border: 'none', color: COLORS.error, cursor: 'pointer', fontSize: '11px', padding: '2px 4px' }}
183
- >
184
- ✕ {i18n('ai_helper_student_remove')}
192
+ <div style={{ background: A.inputBg, border: `1px solid ${A.cardBorder}`, borderRadius: '10px', padding: '8px', margin: '8px 16px 0', fontSize: '11px' }}>
193
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
194
+ <span style={{ color: A.textMuted }}>📝 {i18n('ai_helper_student_code_attached')} ({code.length} {i18n('ai_helper_student_chars')})</span>
195
+ <button type="button" onClick={onCodeClear} style={{ display: 'flex', alignItems: 'center', gap: '3px', background: 'none', border: 'none', color: A.error, cursor: 'pointer', fontSize: '11px', padding: '2px 4px', fontFamily: 'inherit' }}>
196
+ <RemoveIcon size={11} /> {i18n('ai_helper_student_remove')}
185
197
  </button>
186
198
  </div>
187
- <pre style={{
188
- margin: 0, fontFamily: 'Consolas, Monaco, "Courier New", monospace',
189
- whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: COLORS.textPrimary, maxHeight: '60px', overflow: 'auto',
190
- }}>
199
+ <pre style={{ margin: 0, fontFamily: 'Consolas, Monaco, "Courier New", monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: A.textPrimary, maxHeight: '60px', overflow: 'auto' }}>
191
200
  {code.length > 200 ? code.substring(0, 200) + '...' : code}
192
201
  </pre>
193
202
  </div>
194
203
  )}
195
204
 
196
- {/* Unified input card */}
197
- <div className="chat-input-card" style={{
198
- margin: '12px', padding: '10px 12px 10px 16px', borderRadius: '24px',
199
- border: `1px solid ${COLORS.border}`, backgroundColor: '#f8fafc',
200
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.04)', transition: 'all 200ms ease',
205
+ {/* 统一输入卡片 */}
206
+ <div className="ai-input-card" style={{
207
+ margin: '12px 16px', padding: '11px 12px', borderRadius: '16px',
208
+ border: `1px solid ${A.cardBorder}`, background: A.inputBg,
209
+ transition: 'all 200ms ease',
201
210
  }}>
202
- {renderTextarea(isFirstConversation ? '48px' : '24px', '120px')}
203
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '4px' }}>
204
- {isFirstConversation ? renderIncludeCodeCheckbox(`📎 ${i18n('ai_helper_student_attach_current_code')}`) : <div />}
211
+ {renderTextarea(isFirstConversation ? '40px' : '24px', '120px')}
212
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '10px' }}>
213
+ {isFirstConversation ? renderIncludeCodeCheckbox(i18n('ai_helper_student_attach_current_code')) : <div />}
205
214
  {isLoading ? (
206
- <button
207
- onClick={onCancel}
208
- style={{ ...getButtonStyle('danger'), whiteSpace: 'nowrap', borderRadius: RADIUS.full, padding: '6px 16px', fontSize: '13px' }}
209
- >
215
+ <button onClick={onCancel} style={{ background: A.error, color: '#fff', border: 'none', borderRadius: '999px', padding: '6px 16px', fontSize: '13px', cursor: 'pointer', whiteSpace: 'nowrap', fontFamily: 'inherit' }}>
210
216
  {i18n('ai_helper_student_cancel')}
211
217
  </button>
212
218
  ) : (
213
- <button
214
- onClick={onSubmit} disabled={disabledSubmit}
215
- style={submitStyle}
216
- title={i18n('ai_helper_student_send_shortcut')}
217
- >
218
- &#x27A4;
219
+ <button onClick={onSubmit} disabled={disabledSubmit} style={submitStyle} title={i18n('ai_helper_student_send_shortcut')}>
220
+ <SendIcon size={15} />
219
221
  </button>
220
222
  )}
221
223
  </div>
@@ -1,10 +1,27 @@
1
- import React from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import { i18n } from '@hydrooj/ui-default';
3
3
  import { renderMarkdown as renderMarkdownSafe, renderStreamingMarkdown } from '../utils/markdown';
4
- import { COLORS, SPACING, RADIUS, SHADOWS, ZINDEX, FONT_FAMILY } from '../utils/styles';
4
+ import { ZINDEX } from '../utils/styles';
5
5
  import { ThinkingBlock } from './ThinkingBlock';
6
+ import { AIMark } from './icons';
6
7
  import type { Message, ProblemInfo } from './types';
7
8
 
9
+ // ── 方案 A · 克制蓝 调色(局部常量,避免改动共享的 styles.ts)──────────────
10
+ const A = {
11
+ border: '#eef1f5',
12
+ problemBg: '#f7f9fc',
13
+ pillBg: '#e7eeff',
14
+ pillText: '#2563eb',
15
+ textPrimary: '#1e2536',
16
+ textSecondary: '#64748b',
17
+ textMuted: '#94a3b8',
18
+ textFaint: '#aab4c2',
19
+ aiBubbleBg: '#f4f8ff',
20
+ aiBubbleBorder: '#e2ecfb',
21
+ userBubbleBg: '#2563eb',
22
+ mono: "'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace",
23
+ };
24
+
8
25
  interface ParsedContent {
9
26
  content: string;
10
27
  isThinkingStreaming: boolean;
@@ -16,12 +33,10 @@ function parseMessageContent(text: string): ParsedContent {
16
33
 
17
34
  const thinkEnd = text.indexOf('</think>');
18
35
  if (thinkEnd === -1) {
19
- // Think tag opened but not closed — still streaming thinking
20
36
  const content = text.substring(0, thinkStart);
21
37
  return { content, isThinkingStreaming: true };
22
38
  }
23
39
 
24
- // Strip the entire <think>...</think> block (content is just a placeholder)
25
40
  const content = text.substring(0, thinkStart) + text.substring(thinkEnd + 8);
26
41
  return { content: content.trim(), isThinkingStreaming: false };
27
42
  }
@@ -49,7 +64,7 @@ const renderMarkdown = (text: string, streaming?: boolean) => {
49
64
  <div
50
65
  className="markdown-body"
51
66
  dangerouslySetInnerHTML={{ __html: html }}
52
- style={{ fontSize: '13px', lineHeight: '1.6' }}
67
+ style={{ fontSize: '13px', lineHeight: '1.7' }}
53
68
  />
54
69
  );
55
70
  };
@@ -63,11 +78,11 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
63
78
  const renderProblemInfoCard = () => {
64
79
  if (problemInfo) {
65
80
  return (
66
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 16px', background: 'rgba(248,250,252,0.8)', borderBottom: `1px solid ${COLORS.border}` }}>
67
- <span style={{ fontSize: '11px', fontWeight: 'bold', padding: '2px 6px', background: COLORS.primaryLight, color: COLORS.primary, borderRadius: '4px' }}>
81
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '9px 16px', background: A.problemBg, borderBottom: `1px solid ${A.border}` }}>
82
+ <span style={{ fontSize: '11px', fontWeight: 700, padding: '2px 7px', background: A.pillBg, color: A.pillText, borderRadius: '5px', fontFamily: A.mono, flexShrink: 0 }}>
68
83
  {problemInfo.problemId}
69
84
  </span>
70
- <span style={{ fontSize: '13px', color: COLORS.textSecondary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
85
+ <span style={{ fontSize: '12.5px', color: A.textSecondary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
71
86
  {problemInfo.title}
72
87
  </span>
73
88
  </div>
@@ -75,19 +90,14 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
75
90
  }
76
91
  if (problemInfoError) {
77
92
  return (
78
- <div style={{
79
- display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 16px', background: COLORS.warningBg, borderBottom: `1px solid ${COLORS.warningBorder}`
80
- }}>
81
- <span style={{ fontSize: '12px', color: COLORS.warningText, whiteSpace: 'nowrap' }}>⚠️ {i18n('ai_helper_student_cannot_get_problem')}</span>
93
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '9px 16px', background: '#fffbeb', borderBottom: '1px solid #fde68a' }}>
94
+ <span style={{ fontSize: '12px', color: '#92400e', whiteSpace: 'nowrap' }}>⚠ {i18n('ai_helper_student_cannot_get_problem')}</span>
82
95
  <input
83
96
  type="text"
84
97
  placeholder={i18n('ai_helper_student_manual_title_placeholder')}
85
98
  value={manualTitle}
86
99
  onChange={(e) => onManualTitleChange(e.target.value)}
87
- style={{
88
- flex: 1, padding: SPACING.xs, border: `1px solid ${COLORS.warningBorder}`,
89
- borderRadius: RADIUS.sm, fontSize: '12px', boxSizing: 'border-box'
90
- }}
100
+ style={{ flex: 1, padding: '4px 8px', border: '1px solid #fde68a', borderRadius: '6px', fontSize: '12px', boxSizing: 'border-box' }}
91
101
  />
92
102
  </div>
93
103
  );
@@ -98,16 +108,16 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
98
108
  const renderEmptyState = () => (
99
109
  <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, padding: '40px 24px', textAlign: 'center' }}>
100
110
  <div style={{
101
- width: '72px', height: '72px', borderRadius: '50%',
102
- background: 'linear-gradient(135deg, #2563eb, #3b82f6)',
111
+ width: '64px', height: '64px', borderRadius: '18px',
112
+ background: 'linear-gradient(135deg, #2563eb, #5b8def)',
103
113
  display: 'flex', alignItems: 'center', justifyContent: 'center',
104
- fontSize: '36px', marginBottom: '24px',
105
- boxShadow: '0 8px 24px rgba(37, 99, 235, 0.2)',
106
- animation: 'float 3s ease-in-out infinite',
107
- }}>🤖</div>
108
- <div style={{ fontSize: '18px', fontWeight: 600, color: COLORS.textPrimary, marginBottom: '8px' }}>{i18n('ai_helper_student_welcome_title')}</div>
109
- <div style={{ fontSize: '13px', color: COLORS.textSecondary, lineHeight: '1.8' }}>
110
- {i18n('ai_helper_student_welcome_desc_line1')}<br/>{i18n('ai_helper_student_welcome_desc_line2')}
114
+ fontFamily: A.mono, fontSize: '24px', fontWeight: 700, color: '#fff',
115
+ letterSpacing: '-1px', marginBottom: '18px',
116
+ boxShadow: '0 8px 20px rgba(37, 99, 235, 0.25)',
117
+ }}>AI</div>
118
+ <div style={{ fontSize: '17px', fontWeight: 700, color: A.textPrimary, marginBottom: '6px' }}>{i18n('ai_helper_student_welcome_title')}</div>
119
+ <div style={{ fontSize: '12.5px', color: A.textMuted, lineHeight: '1.7' }}>
120
+ {i18n('ai_helper_student_welcome_desc_line1')}<br />{i18n('ai_helper_student_welcome_desc_line2')}
111
121
  </div>
112
122
  </div>
113
123
  );
@@ -116,20 +126,18 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
116
126
  const parsed = msg.role === 'ai' ? parseMessageContent(msg.content) : null;
117
127
  const isStudent = msg.role === 'student';
118
128
  return (
119
- <div key={idx} style={{ display: 'flex', flexDirection: isStudent ? 'row-reverse' : 'row', gap: SPACING.sm, alignItems: 'flex-start' }}>
120
- {/* Avatar indicator */}
121
- <div style={{
122
- width: '28px', height: '28px', borderRadius: RADIUS.full, flexShrink: 0,
123
- display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px',
124
- background: isStudent ? COLORS.primary : COLORS.primaryLight,
125
- color: isStudent ? '#ffffff' : COLORS.primary,
126
- border: isStudent ? 'none' : `1px solid ${COLORS.border}`,
127
- }}>
128
- {isStudent ? '\u{1F464}' : '\u{1F916}'}
129
- </div>
130
- <div style={{ maxWidth: '80%', display: 'flex', flexDirection: 'column', gap: '2px' }}>
129
+ <div key={idx} style={{ display: 'flex', flexDirection: isStudent ? 'row-reverse' : 'row', gap: '8px', alignItems: 'flex-start' }}>
130
+ {/* Avatar */}
131
+ {isStudent ? (
132
+ <div style={{ width: '28px', height: '28px', borderRadius: '50%', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px', fontWeight: 600, background: A.userBubbleBg, color: '#fff' }}>
133
+ {i18n('ai_helper_student_me')}
134
+ </div>
135
+ ) : (
136
+ <AIMark size={28} radius={8} fontSize={10} />
137
+ )}
138
+ <div style={{ maxWidth: '82%', display: 'flex', flexDirection: 'column', gap: '3px' }}>
131
139
  {/* Speaker label */}
132
- <div style={{ fontSize: '11px', color: COLORS.textMuted, textAlign: isStudent ? 'right' : 'left' }}>
140
+ <div style={{ fontSize: '10.5px', color: A.textFaint, textAlign: isStudent ? 'right' : 'left' }}>
133
141
  {isStudent ? i18n('ai_helper_student_me') : i18n('ai_helper_student_ai_assistant')}
134
142
  </div>
135
143
  {/* Bubble */}
@@ -137,13 +145,12 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
137
145
  data-ai-message={msg.role === 'ai' ? 'true' : undefined}
138
146
  data-message-id={msg.role === 'ai' ? msg.id : undefined}
139
147
  style={{
140
- padding: `${SPACING.sm} ${SPACING.md}`,
141
- borderRadius: isStudent ? '12px 12px 4px 12px' : '12px 12px 12px 4px',
142
- background: isStudent ? COLORS.primary : '#f0f7ff',
143
- color: isStudent ? '#ffffff' : COLORS.textPrimary,
144
- fontSize: '13px', lineHeight: '1.6',
145
- border: isStudent ? 'none' : '1px solid #dbeafe',
146
- boxShadow: SHADOWS.sm,
148
+ padding: '10px 13px',
149
+ borderRadius: isStudent ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
150
+ background: isStudent ? A.userBubbleBg : A.aiBubbleBg,
151
+ color: isStudent ? '#ffffff' : A.textPrimary,
152
+ fontSize: '13px', lineHeight: '1.7',
153
+ border: isStudent ? 'none' : `1px solid ${A.aiBubbleBorder}`,
147
154
  }}
148
155
  >
149
156
  {msg.role === 'ai' && parsed ? (
@@ -152,18 +159,12 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
152
159
  <div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
153
160
  )}
154
161
  </div>
155
- {/* Attached code block for student messages */}
162
+ {/* Attached code (student) */}
156
163
  {isStudent && msg.code && (
157
- <div style={{
158
- background: COLORS.bgPage, border: `1px solid ${COLORS.border}`,
159
- borderRadius: RADIUS.md, padding: SPACING.sm, fontSize: '12px',
160
- maxWidth: '100%', overflow: 'hidden',
161
- }}>
162
- <div style={{ fontSize: '11px', color: COLORS.textMuted, marginBottom: SPACING.xs, display: 'flex', alignItems: 'center', gap: SPACING.xs }}>
163
- 📝 {i18n('ai_helper_student_attached_code')}
164
- </div>
164
+ <div style={{ background: '#f8fafc', border: `1px solid ${A.border}`, borderRadius: '10px', padding: '8px', fontSize: '12px', maxWidth: '100%', overflow: 'hidden' }}>
165
+ <div style={{ fontSize: '11px', color: A.textMuted, marginBottom: '4px' }}>📝 {i18n('ai_helper_student_attached_code')}</div>
165
166
  <div className="markdown-body" dangerouslySetInnerHTML={{
166
- __html: renderMarkdownSafe(`\`\`\`\n${msg.code.length > 500 ? msg.code.substring(0, 500) + `\n// ... ${i18n('ai_helper_student_code_truncated')}` : msg.code}\n\`\`\``).innerHTML
167
+ __html: renderMarkdownSafe(`\`\`\`\n${msg.code.length > 500 ? msg.code.substring(0, 500) + `\n// ... ${i18n('ai_helper_student_code_truncated')}` : msg.code}\n\`\`\``),
167
168
  }} />
168
169
  </div>
169
170
  )}
@@ -172,36 +173,55 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
172
173
  );
173
174
  };
174
175
 
176
+ // Memoize message nodes on `messages`. Selecting text only changes popup
177
+ // state, so without this the whole list re-renders on every selection — and
178
+ // in the live runtime that re-render coincided with the AI bubble's rendered
179
+ // DOM being replaced (observed via MutationObserver), which detached the
180
+ // user's selection and collapsed the highlight. Stable element references
181
+ // make React bail out of these subtrees, so the selection's DOM (and the
182
+ // native highlight) survive; it also avoids re-rendering completed messages
183
+ // on every keystroke. (renderMarkdown itself is deterministic — verified
184
+ // end-to-end — so this is not about unstable HTML output.)
185
+ const messageNodes = useMemo(
186
+ () => messages.map((msg, idx) => renderMessage(msg, idx)),
187
+ [messages],
188
+ );
189
+
175
190
  return (
176
191
  <div
177
192
  ref={chatContainerRef}
178
193
  onMouseUp={onTextSelection}
179
- style={{ flex: 1, overflowY: 'auto', padding: SPACING.base, display: 'flex', flexDirection: 'column', gap: SPACING.md }}
194
+ style={{ flex: 1, overflowY: 'auto', padding: '18px 16px', display: 'flex', flexDirection: 'column', gap: '16px' }}
180
195
  >
181
- {/* Problem info breadcrumb */}
182
- {renderProblemInfoCard()}
196
+ {/* dot-pulse keyframes for streaming/loading */}
197
+ <style>{`@keyframes dotpulse{0%,80%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}`}</style>
198
+
199
+ {/* Problem info breadcrumb (full-bleed: negate the body padding) */}
200
+ <div style={{ margin: '-18px -16px 0' }}>{renderProblemInfoCard()}</div>
183
201
 
184
202
  {/* Empty state */}
185
203
  {messages.length === 0 && !isStreaming && !isLoading && renderEmptyState()}
186
204
 
187
- {/* Messages */}
188
- {messages.map((msg, idx) => renderMessage(msg, idx))}
205
+ {/* Messages (memoized — see messageNodes above) */}
206
+ {messageNodes}
189
207
 
190
208
  {/* Streaming output */}
191
209
  {isStreaming && streamingContent && (() => {
192
210
  const parsed = parseMessageContent(streamingContent);
193
211
  return (
194
- <div style={{ display: 'flex', flexDirection: 'row', gap: SPACING.sm }}>
212
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '8px', alignItems: 'flex-start' }}>
213
+ <AIMark size={28} radius={8} fontSize={10} />
195
214
  <div style={{
196
- maxWidth: '85%', padding: '10px 14px', borderRadius: '12px 12px 12px 4px',
197
- background: COLORS.primaryLight, color: COLORS.textPrimary, fontSize: '13px', lineHeight: '1.6'
215
+ maxWidth: '82%', padding: '11px 14px', borderRadius: '14px 14px 14px 4px',
216
+ background: A.aiBubbleBg, border: `1px solid ${A.aiBubbleBorder}`,
217
+ color: A.textPrimary, fontSize: '13px', lineHeight: '1.7',
198
218
  }}>
199
219
  <ThinkingBlock isStreaming={parsed.isThinkingStreaming} />
200
220
  {(!parsed.isThinkingStreaming && parsed.content) && renderMarkdown(parsed.content, true)}
201
221
  {!parsed.isThinkingStreaming && (
202
222
  <span style={{
203
- display: 'inline-block', width: '6px', height: '14px', background: COLORS.primary,
204
- marginLeft: '2px', animation: 'blink 1s step-end infinite', verticalAlign: 'text-bottom'
223
+ display: 'inline-block', width: '6px', height: '14px', background: A.pillText,
224
+ marginLeft: '2px', animation: 'blink 1s step-end infinite', verticalAlign: 'text-bottom',
205
225
  }} />
206
226
  )}
207
227
  </div>
@@ -209,12 +229,17 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
209
229
  );
210
230
  })()}
211
231
 
212
- {/* Loading indicator */}
232
+ {/* Loading (pre-stream) */}
213
233
  {isLoading && !isStreaming && (
214
- <div style={{ display: 'flex', gap: SPACING.sm }}>
215
- <div style={{ padding: '10px 14px', borderRadius: '12px 12px 12px 4px', background: COLORS.primaryLight, color: COLORS.textSecondary, fontSize: '13px' }}>
216
- <span style={{ animation: 'pulse 1.5s ease-in-out infinite' }}>{i18n('ai_helper_student_loading')}</span>
217
- <span style={{ color: COLORS.textMuted, fontSize: '11px', marginLeft: SPACING.sm }}>{i18n('ai_helper_student_click_cancel')}</span>
234
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
235
+ <AIMark size={28} radius={8} fontSize={10} />
236
+ <div style={{ padding: '12px 14px', borderRadius: '14px 14px 14px 4px', background: A.aiBubbleBg, border: `1px solid ${A.aiBubbleBorder}`, color: A.textSecondary, fontSize: '13px', display: 'flex', alignItems: 'center', gap: '8px' }}>
237
+ <span>{i18n('ai_helper_student_loading')}</span>
238
+ <span style={{ display: 'inline-flex', gap: '3px' }}>
239
+ <span style={{ width: '5px', height: '5px', borderRadius: '50%', background: A.pillText, animation: 'dotpulse 1.2s infinite' }} />
240
+ <span style={{ width: '5px', height: '5px', borderRadius: '50%', background: A.pillText, animation: 'dotpulse 1.2s infinite 0.2s' }} />
241
+ <span style={{ width: '5px', height: '5px', borderRadius: '50%', background: A.pillText, animation: 'dotpulse 1.2s infinite 0.4s' }} />
242
+ </span>
218
243
  </div>
219
244
  </div>
220
245
  )}
@@ -225,9 +250,9 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
225
250
  style={{
226
251
  position: 'fixed', left: popupPosition.x, top: popupPosition.y,
227
252
  transform: 'translateX(-50%)', zIndex: ZINDEX.dropdown,
228
- background: COLORS.textPrimary, color: '#ffffff', padding: `6px ${SPACING.md}`,
229
- borderRadius: RADIUS.md, fontSize: '12px', cursor: 'pointer',
230
- boxShadow: SHADOWS.md, whiteSpace: 'nowrap'
253
+ background: A.textPrimary, color: '#ffffff', padding: '6px 12px',
254
+ borderRadius: '8px', fontSize: '12px', cursor: 'pointer',
255
+ boxShadow: '0 4px 12px rgba(0,0,0,.18)', whiteSpace: 'nowrap',
231
256
  }}
232
257
  onMouseDown={(e) => e.preventDefault()}
233
258
  onClick={onDontUnderstand}