Skip to content

Commit 23fcbf9

Browse files
unraidclaude
andcommitted
feat: 添加 UI 组件增强与测试覆盖
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 23bb09d commit 23fcbf9

13 files changed

Lines changed: 1331 additions & 1298 deletions

src/components/EffortIndicator.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
EFFORT_LOW,
44
EFFORT_MAX,
55
EFFORT_MEDIUM,
6+
EFFORT_XHIGH,
67
} from '../constants/figures.js'
78
import {
89
type EffortLevel,
@@ -32,6 +33,8 @@ export function effortLevelToSymbol(level: EffortLevel): string {
3233
return EFFORT_MEDIUM
3334
case 'high':
3435
return EFFORT_HIGH
36+
case 'xhigh':
37+
return EFFORT_XHIGH
3538
case 'max':
3639
return EFFORT_MAX
3740
default:
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { afterEach, describe, expect, mock, test } from 'bun:test';
2+
import * as React from 'react';
3+
import { renderToString } from '../../../utils/staticRender.js';
4+
import type { Message } from '../../../types/message.js';
5+
6+
let transcriptShareDismissed = false;
7+
let productFeedbackAllowed = true;
8+
const mockSubmitTranscriptShare = mock(async () => ({ success: true }));
9+
10+
mock.module('../../../utils/config.js', () => ({
11+
getGlobalConfig: () => ({ transcriptShareDismissed }),
12+
saveGlobalConfig: (
13+
updater: (current: { transcriptShareDismissed?: boolean }) => {
14+
transcriptShareDismissed?: boolean;
15+
},
16+
) => {
17+
const next = updater({ transcriptShareDismissed });
18+
transcriptShareDismissed = next.transcriptShareDismissed ?? false;
19+
},
20+
}));
21+
mock.module('../../../services/policyLimits/index.js', () => ({
22+
isPolicyAllowed: () => productFeedbackAllowed,
23+
}));
24+
mock.module('../submitTranscriptShare.js', () => ({
25+
submitTranscriptShare: mockSubmitTranscriptShare,
26+
}));
27+
28+
const { useFrustrationDetection } = await import('../useFrustrationDetection.js');
29+
30+
type DetectionResult = ReturnType<typeof useFrustrationDetection>;
31+
32+
function apiError(uuid: string): Message {
33+
return {
34+
type: 'assistant',
35+
uuid: uuid as any,
36+
isApiErrorMessage: true,
37+
message: { role: 'assistant', content: [] },
38+
};
39+
}
40+
41+
async function renderDetection(props: {
42+
messages: Message[];
43+
isLoading?: boolean;
44+
hasActivePrompt?: boolean;
45+
otherSurveyOpen?: boolean;
46+
}): Promise<DetectionResult> {
47+
let result: DetectionResult | null = null;
48+
function Probe(): React.ReactNode {
49+
result = useFrustrationDetection(
50+
props.messages,
51+
props.isLoading ?? false,
52+
props.hasActivePrompt ?? false,
53+
props.otherSurveyOpen ?? false,
54+
);
55+
return null;
56+
}
57+
58+
await renderToString(<Probe />);
59+
if (!result) {
60+
throw new Error('useFrustrationDetection did not render');
61+
}
62+
return result;
63+
}
64+
65+
afterEach(() => {
66+
transcriptShareDismissed = false;
67+
productFeedbackAllowed = true;
68+
mockSubmitTranscriptShare.mockClear();
69+
});
70+
71+
describe('useFrustrationDetection', () => {
72+
test('stays closed without frustration signals', async () => {
73+
const result = await renderDetection({ messages: [] });
74+
75+
expect(result.state).toBe('closed');
76+
expect(typeof result.handleTranscriptSelect).toBe('function');
77+
});
78+
79+
test('opens a transcript prompt for repeated API errors', async () => {
80+
const result = await renderDetection({
81+
messages: [apiError('a'), apiError('b')],
82+
});
83+
84+
expect(result.state).toBe('transcript_prompt');
85+
});
86+
87+
test('does not prompt while loading, prompting, blocked by another survey, dismissed, or policy-denied', async () => {
88+
const messages = [apiError('a'), apiError('b')];
89+
90+
expect((await renderDetection({ messages, isLoading: true })).state).toBe('closed');
91+
expect((await renderDetection({ messages, hasActivePrompt: true })).state).toBe('closed');
92+
expect((await renderDetection({ messages, otherSurveyOpen: true })).state).toBe('closed');
93+
94+
transcriptShareDismissed = true;
95+
expect((await renderDetection({ messages })).state).toBe('closed');
96+
97+
transcriptShareDismissed = false;
98+
productFeedbackAllowed = false;
99+
expect((await renderDetection({ messages })).state).toBe('closed');
100+
});
101+
102+
test('submits transcript share when the user accepts', async () => {
103+
const result = await renderDetection({
104+
messages: [apiError('a'), apiError('b')],
105+
});
106+
107+
result.handleTranscriptSelect('yes');
108+
await new Promise(resolve => setTimeout(resolve, 0));
109+
110+
expect(mockSubmitTranscriptShare).toHaveBeenCalledWith(
111+
[apiError('a'), apiError('b')],
112+
'frustration',
113+
expect.any(String),
114+
);
115+
});
116+
});
Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,59 @@
1-
// Auto-generated stub — replace with real implementation
1+
import { useState } from 'react'
2+
import type { Message } from '../../types/message.js'
3+
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
4+
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
5+
import { submitTranscriptShare } from './submitTranscriptShare.js'
6+
7+
type FrustrationState = 'closed' | 'transcript_prompt' | 'submitted'
8+
9+
export type FrustrationDetectionResult = {
10+
state: FrustrationState
11+
handleTranscriptSelect: (choice: string) => void
12+
}
13+
14+
function detectFrustration(messages: Message[]): boolean {
15+
const apiErrors = messages.filter(m => (m as any).isApiErrorMessage)
16+
return apiErrors.length >= 2
17+
}
18+
219
export function useFrustrationDetection(
3-
_messages: unknown[],
4-
_isLoading: boolean,
5-
_hasActivePrompt: boolean,
6-
_otherSurveyOpen: boolean,
7-
): { state: 'closed' | 'open'; handleTranscriptSelect: () => void } {
8-
return { state: 'closed', handleTranscriptSelect: () => {} };
20+
messages: Message[],
21+
isLoading: boolean,
22+
hasActivePrompt: boolean,
23+
otherSurveyOpen: boolean,
24+
): FrustrationDetectionResult {
25+
const [state, setState] = useState<FrustrationState>('closed')
26+
27+
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
28+
if (config.transcriptShareDismissed) {
29+
return { state: 'closed', handleTranscriptSelect: () => {} }
30+
}
31+
32+
if (!isPolicyAllowed('product_feedback' as any)) {
33+
return { state: 'closed', handleTranscriptSelect: () => {} }
34+
}
35+
36+
if (isLoading || hasActivePrompt || otherSurveyOpen) {
37+
return { state: 'closed', handleTranscriptSelect: () => {} }
38+
}
39+
40+
const frustrated = detectFrustration(messages)
41+
42+
const effectiveState =
43+
frustrated && state === 'closed' ? 'transcript_prompt' : state
44+
45+
function handleTranscriptSelect(choice: string) {
46+
if (choice === 'yes') {
47+
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
48+
setState('submitted')
49+
} else {
50+
saveGlobalConfig((current: any) => ({
51+
...current,
52+
transcriptShareDismissed: true,
53+
}))
54+
setState('closed')
55+
}
56+
}
57+
58+
return { state: effectiveState, handleTranscriptSelect }
959
}

src/components/InvalidConfigDialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export async function showInvalidConfigDialog({
8383
theme: SAFE_ERROR_THEME_NAME,
8484
}
8585

86+
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: render must be awaited inside executor
8687
await new Promise<void>(async resolve => {
8788
const { unmount } = await render(
8889
<AppStateProvider>

0 commit comments

Comments
 (0)