Skip to content

Commit 1d6bb46

Browse files
committed
fix: better ui for feedback modal
1 parent 56754f0 commit 1d6bb46

File tree

5 files changed

+158
-82
lines changed

5 files changed

+158
-82
lines changed

cli/src/chat.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,26 @@ export const Chat = ({
450450
sendMessageRef,
451451
})
452452

453+
// Feedback state and handlers
454+
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false)
455+
const [feedbackMessageId, setFeedbackMessageId] = useState<string | null>(null)
456+
457+
const openFeedbackForMessage = useCallback((id: string) => {
458+
setFeedbackMessageId(id)
459+
setIsFeedbackOpen(true)
460+
}, [])
461+
462+
const openFeedbackForLatestMessage = useCallback(() => {
463+
const latest = [...messages]
464+
.reverse()
465+
.find((m) => m.variant === 'ai' && m.isComplete)
466+
if (!latest) {
467+
return false
468+
}
469+
openFeedbackForMessage(latest.id)
470+
return true
471+
}, [messages, openFeedbackForMessage])
472+
453473
const handleSubmit = useCallback(
454474
() =>
455475
routeUserPrompt({
@@ -630,14 +650,6 @@ export const Chat = ({
630650
/>
631651
)
632652

633-
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false)
634-
const [feedbackMessageId, setFeedbackMessageId] = useState<string | null>(null)
635-
636-
const openFeedbackForMessage = useCallback((id: string) => {
637-
setFeedbackMessageId(id)
638-
setIsFeedbackOpen(true)
639-
}, [])
640-
641653
// Ctrl+F to open feedback for latest completed AI message
642654
useKeyboard(
643655
useCallback(
@@ -646,14 +658,10 @@ export const Chat = ({
646658
if ('preventDefault' in key && typeof key.preventDefault === 'function') {
647659
key.preventDefault()
648660
}
649-
const latest = [...messages].reverse().find((m) => m.variant === 'ai' && m.isComplete)
650-
if (latest) {
651-
setFeedbackMessageId(latest.id)
652-
setIsFeedbackOpen(true)
653-
}
661+
openFeedbackForLatestMessage()
654662
}
655663
},
656-
[messages],
664+
[openFeedbackForLatestMessage],
657665
),
658666
)
659667

@@ -875,7 +883,7 @@ export const Chat = ({
875883
? 'Enter a coding task'
876884
: 'Enter a coding task or / for commands'
877885
}
878-
focused={inputFocused}
886+
focused={inputFocused && !isFeedbackOpen}
879887
maxHeight={5}
880888
width={inputWidth}
881889
onKeyIntercept={handleSuggestionMenuKey}
@@ -958,7 +966,7 @@ export const Chat = ({
958966
open={isFeedbackOpen}
959967
message={messages.find((m) => m.id === feedbackMessageId) ?? null}
960968
onClose={() => setIsFeedbackOpen(false)}
961-
onSubmit={(text) => {
969+
onSubmit={(data) => {
962970
const target = messages.find((m) => m.id === feedbackMessageId)
963971
const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({
964972
id: m.id,
@@ -977,7 +985,8 @@ export const Chat = ({
977985
credits: target?.credits,
978986
agentMode,
979987
sessionCreditsUsed,
980-
feedbackText: text,
988+
feedbackCategory: data.category,
989+
feedbackText: data.text,
981990
runState: target?.metadata?.runState,
982991
recentMessages: recent,
983992
},

cli/src/components/__tests__/message-block.completion.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,26 @@ describe('MessageBlock completion time', () => {
7878
expect(markup).not.toContain('7s')
7979
expect(markup).not.toContain('3 credits')
8080
})
81+
82+
test('pluralizes credit label correctly', () => {
83+
const singularMarkup = renderToStaticMarkup(
84+
<MessageBlock
85+
{...baseProps}
86+
isComplete={true}
87+
completionTime="7s"
88+
credits={1}
89+
/>,
90+
)
91+
expect(singularMarkup).toContain('1 credit')
92+
93+
const pluralMarkup = renderToStaticMarkup(
94+
<MessageBlock
95+
{...baseProps}
96+
isComplete={true}
97+
completionTime="7s"
98+
credits={4}
99+
/>,
100+
)
101+
expect(pluralMarkup).toContain('4 credits')
102+
})
81103
})

cli/src/components/feedback-icon-button.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,14 @@ export const FeedbackIconButton: React.FC<FeedbackIconButtonProps> = ({ onClick,
4242
style={{
4343
flexDirection: 'row',
4444
alignItems: 'center',
45-
paddingLeft: 1,
46-
paddingRight: 1,
47-
borderStyle: 'single',
48-
borderColor: hover.isOpen ? theme.foreground : theme.border,
49-
customBorderChars: BORDER_CHARS,
45+
paddingLeft: 0,
46+
paddingRight: 0,
5047
}}
5148
onClick={() => onClick?.()}
5249
onMouseOver={handleMouseOver}
5350
onMouseOut={handleMouseOut}
5451
>
55-
<text style={{ wrapMode: 'none', fg: theme.foreground }}>
52+
<text style={{ wrapMode: 'none', fg: hover.isOpen ? theme.foreground : theme.muted }}>
5653
{hover.isOpen ? textExpanded : textCollapsed}
5754
</text>
5855
</Button>

cli/src/components/feedback-modal.tsx

Lines changed: 105 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
1-
import React, { useCallback, useMemo, useRef, useState } from 'react'
1+
import React, { useCallback, useRef, useState } from 'react'
22
import { useRenderer } from '@opentui/react'
33

44
import { MultilineInput, type MultilineInputHandle } from './multiline-input'
55
import { Button } from './button'
66
import { useTheme } from '../hooks/use-theme'
7+
import { BORDER_CHARS } from '../utils/ui-constants'
78
import type { ChatMessage } from '../types/chat'
89

910
interface FeedbackModalProps {
1011
open: boolean
1112
message: ChatMessage | null
1213
onClose: () => void
13-
onSubmit: (text: string) => void
14+
onSubmit: (data: { text: string; category: string | null }) => void
1415
}
1516

16-
export const FeedbackModal: React.FC<FeedbackModalProps> = ({ open, message, onClose, onSubmit }) => {
17+
export const FeedbackModal: React.FC<FeedbackModalProps> = ({ open, onClose, onSubmit }) => {
1718
const theme = useTheme()
1819
const renderer = useRenderer()
19-
const [value, setValue] = useState('')
20-
const [cursorPosition, setCursorPosition] = useState(0)
21-
const [showDetails, setShowDetails] = useState(false)
20+
const [feedbackText, setFeedbackText] = useState('')
21+
const [feedbackCursor, setFeedbackCursor] = useState(0)
22+
const [category, setCategory] = useState<string>('other')
2223
const inputRef = useRef<MultilineInputHandle | null>(null)
2324

2425
const terminalWidth = renderer?.width || 80
@@ -29,29 +30,25 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({ open, message, onC
2930
const modalLeft = Math.floor((terminalWidth - modalWidth) / 2)
3031
const modalTop = Math.floor((terminalHeight - modalHeight) / 2)
3132

32-
const contextPreview = useMemo(() => {
33-
if (!message) return 'No message context'
34-
const runState = message.metadata?.runState
35-
const safe = {
36-
id: message.id,
37-
variant: message.variant,
38-
timestamp: message.timestamp,
39-
completionTime: message.completionTime,
40-
credits: message.credits,
41-
runStatePreview: runState ? JSON.stringify(runState).slice(0, 1000) + (JSON.stringify(runState).length > 1000 ? ' …' : '') : 'n/a',
42-
}
43-
return JSON.stringify(safe, null, 2)
44-
}, [message])
45-
4633
const handleSubmit = useCallback(() => {
47-
const text = value.trim()
34+
const text = feedbackText.trim()
4835
if (text.length === 0) return
49-
onSubmit(text)
50-
setValue('')
51-
}, [onSubmit, value])
36+
onSubmit({ text, category })
37+
setFeedbackText('')
38+
setCategory('other')
39+
}, [onSubmit, feedbackText, category])
5240

5341
if (!open) return null
5442

43+
const categoryOptions = [
44+
{ id: 'good_code', label: 'Good code', highlight: theme.success },
45+
{ id: 'bad_code', label: 'Bad code', highlight: theme.error },
46+
{ id: 'bug', label: 'Bug', highlight: theme.warning },
47+
{ id: 'other', label: 'Other', highlight: theme.info },
48+
] as const
49+
50+
const canSubmit = feedbackText.trim().length > 0
51+
5552
return (
5653
<box
5754
position="absolute"
@@ -69,61 +66,111 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({ open, message, onC
6966
gap: 1,
7067
}}
7168
>
72-
<text style={{ wrapMode: 'none' }}>
73-
<span fg={theme.primary}>Share Feedback</span>
74-
</text>
69+
<box style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
70+
<text style={{ wrapMode: 'none' }}>
71+
<span fg={theme.primary}>Share Feedback</span>
72+
</text>
73+
<Button
74+
onClick={onClose}
75+
style={{
76+
paddingLeft: 1,
77+
paddingRight: 1,
78+
borderStyle: 'single',
79+
borderColor: theme.border,
80+
customBorderChars: BORDER_CHARS,
81+
}}
82+
>
83+
<text style={{ wrapMode: 'none' }}>
84+
<span fg={theme.muted}>X</span>
85+
</text>
86+
</Button>
87+
</box>
7588

7689
<text style={{ wrapMode: 'word' }}>
7790
<span fg={theme.secondary}>Thanks for helping us improve! What happened?</span>
7891
</text>
7992

80-
<box style={{ flexDirection: 'column' }}>
93+
<box style={{ flexDirection: 'column', gap: 0 }}>
94+
<text style={{ wrapMode: 'none', paddingBottom: 1 }}>
95+
<span fg={theme.muted}>Select a category:</span>
96+
</text>
97+
<box style={{ flexDirection: 'row', gap: 1, flexWrap: 'wrap' }}>
98+
{categoryOptions.map((option) => {
99+
const isSelected = category === option.id
100+
return (
101+
<Button
102+
key={option.id}
103+
onClick={() => setCategory(option.id)}
104+
style={{
105+
flexDirection: 'row',
106+
alignItems: 'center',
107+
gap: 1,
108+
paddingLeft: 1,
109+
paddingRight: 1,
110+
paddingTop: 0,
111+
paddingBottom: 0,
112+
borderStyle: 'single',
113+
borderColor: isSelected ? option.highlight : theme.border,
114+
customBorderChars: BORDER_CHARS,
115+
backgroundColor: isSelected ? theme.surface : undefined,
116+
}}
117+
>
118+
<text style={{ wrapMode: 'none' }}>
119+
<span fg={isSelected ? option.highlight : theme.muted}>{isSelected ? '◉' : '◯'}</span>
120+
<span fg={isSelected ? theme.foreground : theme.secondary}> {option.label}</span>
121+
</text>
122+
</Button>
123+
)
124+
})}
125+
</box>
126+
</box>
127+
128+
<box
129+
border
130+
borderStyle="single"
131+
borderColor={theme.border}
132+
customBorderChars={BORDER_CHARS}
133+
style={{ paddingLeft: 1, paddingRight: 1, paddingTop: 0, paddingBottom: 0 }}
134+
>
81135
<MultilineInput
82-
value={value}
136+
value={feedbackText}
83137
onChange={(next: { text: string; cursorPosition: number; lastEditDueToNav: boolean } | ((prev: { text: string; cursorPosition: number; lastEditDueToNav: boolean }) => { text: string; cursorPosition: number; lastEditDueToNav: boolean })) => {
84-
const v = typeof next === 'function' ? next({ text: value, cursorPosition, lastEditDueToNav: false }) : next
85-
setValue(v.text)
86-
setCursorPosition(v.cursorPosition)
138+
const v = typeof next === 'function' ? next({ text: feedbackText, cursorPosition: feedbackCursor, lastEditDueToNav: false }) : next
139+
setFeedbackText(v.text)
140+
setFeedbackCursor(v.cursorPosition)
87141
}}
88142
onSubmit={handleSubmit}
89143
placeholder={'Tell us more...'}
90144
focused={true}
91145
maxHeight={6}
92-
width={modalWidth - 4}
146+
width={modalWidth - 6}
93147
textAttributes={undefined}
94148
ref={inputRef}
95-
cursorPosition={cursorPosition}
149+
cursorPosition={feedbackCursor}
96150
/>
97151
</box>
98152

99-
<box style={{ flexDirection: 'row', gap: 2, alignItems: 'center' }}>
153+
<box style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
100154
<text style={{ wrapMode: 'none' }}>
101155
<span fg={theme.muted}>Auto-attached: Message content • Trace data • Session info</span>
102156
</text>
103-
<Button onClick={() => setShowDetails((s) => !s)}>
104-
<text style={{ wrapMode: 'none' }}>
105-
<span fg={theme.info}>{showDetails ? '[Hide details]' : '[View details]'}</span>
106-
</text>
107-
</Button>
108-
</box>
109-
110-
{showDetails && (
111-
<box style={{ flexDirection: 'column', maxHeight: 6 }}>
112-
<text style={{ wrapMode: 'word' }}>
113-
<span fg={theme.muted}>{contextPreview}</span>
114-
</text>
115-
</box>
116-
)}
117-
118-
<box style={{ flexDirection: 'row', justifyContent: 'flex-end', gap: 2 }}>
119-
<Button onClick={onClose}>
120-
<text style={{ wrapMode: 'none' }}>
121-
<span fg={theme.error}>Cancel</span>
122-
</text>
123-
</Button>
124-
<Button onClick={handleSubmit}>
157+
<Button
158+
onClick={() => {
159+
if (canSubmit) handleSubmit()
160+
}}
161+
style={{
162+
flexDirection: 'row',
163+
alignItems: 'center',
164+
paddingLeft: 1,
165+
paddingRight: 1,
166+
borderStyle: 'single',
167+
borderColor: canSubmit ? theme.foreground : theme.border,
168+
customBorderChars: BORDER_CHARS,
169+
backgroundColor: canSubmit ? theme.surface : undefined,
170+
}}
171+
>
125172
<text style={{ wrapMode: 'none' }}>
126-
<span fg={theme.success}>Submit</span>
173+
<span fg={canSubmit ? theme.foreground : theme.muted}>{'< SUBMIT'}</span>
127174
</text>
128175
</Button>
129176
</box>

cli/src/components/message-block.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { TextAttributes } from '@opentui/core'
2+
import { pluralize } from '@codebuff/common/util/string'
23
import React, { memo, useCallback, type ReactNode } from 'react'
34

45
import { AgentBranchItem } from './agent-branch-item'
@@ -169,7 +170,7 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => {
169170
}}
170171
>
171172
{completionTime}
172-
{credits && ` • ${credits} credits`}
173+
{typeof credits === 'number' && credits > 0 && ` • ${pluralize(credits, 'credit')}`}
173174
</text>
174175
<FeedbackIconButton onClick={() => onFeedback?.(messageId)} messageId={messageId} />
175176
</box>

0 commit comments

Comments
 (0)