Skip to content

Commit efbc3d2

Browse files
SWangHashSWangHash
authored andcommitted
!39 merge feedback into main
fix: improve feedback input and conversation behavior Created-by: gcw_yzScd72y Commit-by: Peanut-Puff Merged-by: SWangHash Description: ## Summary <!-- Briefly describe what changed. --> Fixes # ## Type and Areas Type: <!-- Feature / bug fix / regression fix / refactor / UI/UX / docs / test / CI / dependency / other. --> Areas: <!-- Rust core, desktop/Tauri, web UI, mobile web, server/relay, AI adapters, installer, docs, etc. --> ## Motivation / Impact <!-- What problem does this solve, and what changes for users or developers? Write "No direct user-facing change" if applicable. --> ## Verification <!-- List exact commands, manual checks, and outcomes. For docs-only or template-only changes, use the lightest relevant checks or explain why runtime checks were skipped. --> ## Reviewer Notes <!-- Optional: screenshots, architecture notes, compatibility risks, migration notes, or rollback guidance. --> ## Checklist - [ ] This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts. - [ ] Relevant verification is recorded above, or skipped checks are explained. - [ ] User-facing strings, docs, and locales are updated where applicable. See merge request: OpenHarmonyPCDeveloper/BitFun!39
2 parents 1ff8506 + d6add3b commit efbc3d2

12 files changed

Lines changed: 604 additions & 44 deletions

File tree

‎src/web-ui/src/app/components/FeedbackDialog/FeedbackConversationView.tsx‎

Lines changed: 172 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { LockKeyhole, RefreshCw, Send } from 'lucide-react';
33
import { Button, ConfirmDialog, IconButton, Textarea } from '@/component-library';
44
import { usePrivacy } from '@/app/components/Privacy/PrivacyContext';
55
import { PrivacyStatementDialog } from '@/app/components/Privacy/PrivacyStatementDialog';
6+
import { useImeEnterGuard } from '@/flow_chat/hooks/useImeEnterGuard';
67
import {
78
feedbackAPI,
89
FeedbackApiError,
10+
FEEDBACK_CONTENT_MAX_CHARS,
11+
feedbackInsertText,
912
type FeedbackMessage,
1013
type FeedbackRecordSummary,
1114
feedbackContentLength,
@@ -14,6 +17,7 @@ import {
1417
import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
1518
import { PrivacyStatementLink } from './PrivacyStatementLink';
1619
import { useFeedbackInboxStore } from './feedbackInboxStore';
20+
import { useRejectedInsertionCaret } from './useRejectedInsertionCaret';
1721

1822
interface FeedbackConversationViewProps {
1923
record: FeedbackRecordSummary;
@@ -30,6 +34,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
3034
}) => {
3135
const { t, formatDate } = useI18n('common');
3236
const { status, accept } = usePrivacy();
37+
const { isImeEnter, handleCompositionStart, handleCompositionEnd } = useImeEnterGuard();
3338
const [messages, setMessages] = useState<FeedbackMessage[]>([]);
3439
const [nextCursor, setNextCursor] = useState<string>();
3540
const [hasMore, setHasMore] = useState(false);
@@ -45,6 +50,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
4550
const [showConsent, setShowConsent] = useState(false);
4651
const [showPrivacy, setShowPrivacy] = useState(false);
4752
const scrollRef = useRef<HTMLDivElement>(null);
53+
const { armRejectedInsertionCaret, restoreRejectedInsertionCaret } = useRejectedInsertionCaret();
4854
const topSentinelRef = useRef<HTMLDivElement>(null);
4955
const visibleAdminTimesRef = useRef(new Set<string>());
5056
const lastReadThroughRef = useRef<string | null>(null);
@@ -58,6 +64,9 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
5864
const markInaccessible = useFeedbackInboxStore(state => state.markInaccessible);
5965
const refreshInbox = useFeedbackInboxStore(state => state.refresh);
6066
const draftLength = feedbackContentLength(draft);
67+
const draftNativeMaxLength = draftLength >= FEEDBACK_CONTENT_MAX_CHARS
68+
? draft.length
69+
: undefined;
6170
const canReply = Boolean(draft.trim()) && !sending && record.status !== 'resolved';
6271

6372
useEffect(() => {
@@ -89,7 +98,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
8998
const loadLatest = useCallback(async (manual: boolean) => {
9099
if (manual) setRefreshing(true);
91100
else setLoading(true);
92-
setError(null);
101+
if (!manual) setError(null);
93102
try {
94103
const page = await feedbackAPI.openConversation({ feedbackId: record.feedbackId });
95104
if (!mountedRef.current) return;
@@ -105,12 +114,12 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
105114
refreshedReconciledUnreadRef.current = true;
106115
void refreshInbox(true);
107116
}
108-
if (!manual) {
109-
requestAnimationFrame(() => {
110-
const container = scrollRef.current;
111-
if (container) container.scrollTop = container.scrollHeight;
112-
});
113-
}
117+
// Refresh can append newly arrived messages to the existing list. Wait
118+
// for the merged list to render, then keep the conversation at its tail.
119+
requestAnimationFrame(() => {
120+
const container = scrollRef.current;
121+
if (container) container.scrollTop = container.scrollHeight;
122+
});
114123
} catch (caught) {
115124
if (mountedRef.current) handleConversationError(caught);
116125
} finally {
@@ -240,6 +249,45 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
240249
void sendReply();
241250
};
242251

252+
const handleReplyKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
253+
if (
254+
!event.ctrlKey
255+
&& !event.metaKey
256+
&& !event.altKey
257+
&& (
258+
event.key.length === 1
259+
|| event.key === 'Process'
260+
|| event.nativeEvent.keyCode === 229
261+
)
262+
) {
263+
const textarea = event.currentTarget;
264+
if (
265+
textarea.selectionStart === textarea.selectionEnd
266+
&& feedbackContentLength(textarea.value) >= FEEDBACK_CONTENT_MAX_CHARS
267+
) {
268+
armRejectedInsertionCaret(textarea);
269+
}
270+
}
271+
if (event.key !== 'Enter' || isImeEnter(event)) return;
272+
if (event.ctrlKey) {
273+
const textarea = event.currentTarget;
274+
if (
275+
textarea.selectionStart === textarea.selectionEnd
276+
&& feedbackContentLength(textarea.value) >= FEEDBACK_CONTENT_MAX_CHARS
277+
) {
278+
armRejectedInsertionCaret(textarea);
279+
// Let native maxLength reject the newline without touching undo state.
280+
return;
281+
}
282+
event.preventDefault();
283+
applyFeedbackInsertion(event.currentTarget, '\n');
284+
return;
285+
}
286+
// Enter sends the reply; Ctrl+Enter inserts a newline.
287+
event.preventDefault();
288+
event.currentTarget.form?.requestSubmit();
289+
};
290+
243291
const acceptAndSend = async () => {
244292
const content = draft.trim();
245293
if (!content || sending || !status?.policy) return;
@@ -264,13 +312,92 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
264312
if (mountedRef.current) setSending(false);
265313
};
266314

267-
const handleDraftChange = (value: string) => {
315+
const handleDraftChange = (textarea: HTMLTextAreaElement) => {
316+
const value = textarea.value;
317+
if (/^\s/.test(value) && value.replace(/^\s+/, '') === draft) {
318+
document.execCommand('undo');
319+
return;
320+
}
268321
const truncated = truncateFeedbackContent(value);
269-
setDraftTruncated(truncated !== value);
322+
setDraftTruncated(Array.from(value.replace(/^\s+/, '')).length > FEEDBACK_CONTENT_MAX_CHARS);
270323
setDraft(truncated);
271324
setReplyError(null);
272325
};
273326

327+
const applyFeedbackInsertion = (textarea: HTMLTextAreaElement, insertedText: string) => {
328+
const currentValue = textarea.value;
329+
const start = textarea.selectionStart;
330+
const end = textarea.selectionEnd;
331+
if (start === end && feedbackContentLength(currentValue) >= FEEDBACK_CONTENT_MAX_CHARS) {
332+
return;
333+
}
334+
const acceptedText = feedbackInsertText(currentValue, start, end, insertedText);
335+
if (acceptedText && document.execCommand('insertText', false, acceptedText)) {
336+
// execCommand preserves the browser's native undo transaction for a
337+
// paste, unlike replacing the controlled value in onChange.
338+
} else {
339+
textarea.setRangeText(acceptedText, start, end, 'end');
340+
}
341+
const candidate = currentValue.slice(0, start) + insertedText + currentValue.slice(end);
342+
setDraftTruncated(feedbackContentLength(candidate.replace(/^\s+/, '')) > FEEDBACK_CONTENT_MAX_CHARS);
343+
};
344+
345+
const handleDraftPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
346+
const textarea = event.currentTarget;
347+
if (
348+
textarea.selectionStart === textarea.selectionEnd
349+
&& feedbackContentLength(textarea.value) >= FEEDBACK_CONTENT_MAX_CHARS
350+
) {
351+
armRejectedInsertionCaret(textarea);
352+
// Let the native maxLength gate reject the paste without creating a
353+
// JavaScript edit boundary in the browser's undo history.
354+
return;
355+
}
356+
event.preventDefault();
357+
applyFeedbackInsertion(textarea, event.clipboardData.getData('text/plain'));
358+
};
359+
360+
const handleDraftBeforeInput = (event: React.FormEvent<HTMLTextAreaElement>) => {
361+
const nativeEvent = event.nativeEvent as InputEvent;
362+
const textarea = event.currentTarget;
363+
if (
364+
nativeEvent.inputType.startsWith('insert')
365+
&& textarea.selectionStart === textarea.selectionEnd
366+
&& feedbackContentLength(textarea.value) >= FEEDBACK_CONTENT_MAX_CHARS
367+
) {
368+
armRejectedInsertionCaret(textarea);
369+
return;
370+
}
371+
if (
372+
textarea.selectionStart === textarea.selectionEnd
373+
&& feedbackContentLength(textarea.value) >= FEEDBACK_CONTENT_MAX_CHARS
374+
) {
375+
// Native maxLength owns full-input rejection so the browser does not
376+
// create a JavaScript edit boundary that consumes the next undo.
377+
return;
378+
}
379+
const insertedText = nativeEvent.inputType === 'insertLineBreak'
380+
|| nativeEvent.inputType === 'insertParagraph'
381+
? '\n'
382+
: nativeEvent.data;
383+
if (
384+
!['insertText', 'insertLineBreak', 'insertParagraph'].includes(nativeEvent.inputType)
385+
|| nativeEvent.isComposing
386+
|| insertedText == null
387+
) {
388+
return;
389+
}
390+
const acceptedText = feedbackInsertText(
391+
textarea.value,
392+
textarea.selectionStart,
393+
textarea.selectionEnd,
394+
insertedText,
395+
);
396+
if (acceptedText === insertedText) return;
397+
event.preventDefault();
398+
applyFeedbackInsertion(textarea, insertedText);
399+
};
400+
274401
useEffect(() => {
275402
mountedRef.current = true;
276403
setMessages([]);
@@ -340,6 +467,29 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
340467
<RefreshCw size={15} aria-hidden="true" />
341468
</IconButton>
342469
</div>
470+
{error || ackError ? (
471+
<div className="bitfun-feedback__conversation-notices">
472+
{error ? (
473+
<div className="bitfun-feedback__message-error" role="alert">
474+
<span>{conversationErrorText(error.code, t)}</span>
475+
<Button
476+
type="button"
477+
variant="ghost"
478+
size="small"
479+
disabled={sending}
480+
onClick={() => void loadLatest(true)}
481+
>
482+
{t('feedback.actions.retry')}
483+
</Button>
484+
</div>
485+
) : null}
486+
{ackError ? (
487+
<div className="bitfun-feedback__message-notice" role="status">
488+
{t('feedback.conversation.ackFailed')}
489+
</div>
490+
) : null}
491+
</div>
492+
) : null}
343493
<div ref={scrollRef} className="bitfun-feedback__messages" aria-live="polite">
344494
<div ref={topSentinelRef} className="bitfun-feedback__message-sentinel" aria-hidden="true" />
345495
{loading || loadingEarlier ? (
@@ -349,26 +499,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
349499
: t('feedback.conversation.loading')}
350500
</div>
351501
) : null}
352-
{error ? (
353-
<div className="bitfun-feedback__message-error" role="alert">
354-
<span>{conversationErrorText(error.code, t)}</span>
355-
<Button
356-
type="button"
357-
variant="ghost"
358-
size="small"
359-
disabled={sending}
360-
onClick={() => void loadLatest(true)}
361-
>
362-
{t('feedback.actions.retry')}
363-
</Button>
364-
</div>
365-
) : null}
366-
{ackError ? (
367-
<div className="bitfun-feedback__message-notice" role="status">
368-
{t('feedback.conversation.ackFailed')}
369-
</div>
370-
) : null}
371-
{!loading && messages.length === 0 && !error ? (
502+
{!loading && !refreshing && messages.length === 0 && !error ? (
372503
<div className="bitfun-feedback__message-empty">
373504
{t('feedback.conversation.empty')}
374505
</div>
@@ -401,11 +532,22 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
401532
<form className="bitfun-feedback__reply" onSubmit={requestReply}>
402533
<Textarea
403534
value={draft}
535+
maxLength={draftNativeMaxLength}
404536
rows={3}
405537
disabled={sending}
406538
aria-label={t('feedback.reply.input')}
407539
placeholder={t('feedback.reply.placeholder')}
408-
onChange={event => handleDraftChange(event.target.value)}
540+
onKeyDown={handleReplyKeyDown}
541+
onKeyUp={event => restoreRejectedInsertionCaret(event.currentTarget)}
542+
onInput={event => restoreRejectedInsertionCaret(event.currentTarget)}
543+
onCompositionStart={handleCompositionStart}
544+
onCompositionEnd={event => {
545+
handleCompositionEnd();
546+
restoreRejectedInsertionCaret(event.currentTarget);
547+
}}
548+
onPaste={handleDraftPaste}
549+
onBeforeInput={handleDraftBeforeInput}
550+
onChange={event => handleDraftChange(event.target)}
409551
/>
410552
<div className="bitfun-feedback__reply-meta" aria-live="polite">
411553
<span>{draftTruncated ? t('feedback.contentTruncated') : ''}</span>

‎src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,16 @@
299299
gap: 6px;
300300
min-height: 0;
301301
padding: 10px;
302+
overflow-x: hidden;
302303
overflow-y: auto;
303304

305+
&::-webkit-scrollbar { width: 3px; }
306+
&::-webkit-scrollbar-track { background: transparent; }
307+
&::-webkit-scrollbar-thumb {
308+
background: var(--bf-appearance-token-border-subtle);
309+
border-radius: 2px;
310+
}
311+
304312
> .btn {
305313
flex-shrink: 0;
306314
align-self: center;
@@ -488,15 +496,31 @@
488496
font-size: var(--bf-appearance-token-font-size-sm);
489497
}
490498

499+
.bitfun-feedback__conversation-notices {
500+
display: flex;
501+
flex-shrink: 0;
502+
flex-direction: column;
503+
gap: 4px;
504+
padding: 6px 12px 0;
505+
}
506+
491507
.bitfun-feedback__messages {
492508
display: flex;
493509
flex: 1;
494510
flex-direction: column;
495511
gap: 10px;
496512
min-height: 0;
497513
padding: 12px 16px 18px;
514+
overflow-x: hidden;
498515
overflow-y: auto;
499516
overscroll-behavior: contain;
517+
518+
&::-webkit-scrollbar { width: 3px; }
519+
&::-webkit-scrollbar-track { background: transparent; }
520+
&::-webkit-scrollbar-thumb {
521+
background: var(--bf-appearance-token-border-subtle);
522+
border-radius: 2px;
523+
}
500524
}
501525

502526
.bitfun-feedback__message-sentinel {

0 commit comments

Comments
 (0)