Skip to content

Commit 56754f0

Browse files
committed
feat: initial message feedback button
1 parent 52541a2 commit 56754f0

File tree

7 files changed

+297
-9
lines changed

7 files changed

+297
-9
lines changed

cli/src/chat.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { TextAttributes } from '@opentui/core'
2+
import { useKeyboard } from '@opentui/react'
23
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
34
import { useShallow } from 'zustand/react/shallow'
45

@@ -7,6 +8,7 @@ import { AgentModeToggle } from './components/agent-mode-toggle'
78
import { Button } from './components/button'
89
import { LoginModal } from './components/login-modal'
910
import { MessageWithAgents } from './components/message-with-agents'
11+
import { FeedbackModal } from './components/feedback-modal'
1012
import {
1113
MultilineInput,
1214
type MultilineInputHandle,
@@ -36,6 +38,8 @@ import { useSuggestionMenuHandlers } from './hooks/use-suggestion-menu-handlers'
3638
import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
3739
import { useTheme } from './hooks/use-theme'
3840
import { useValidationBanner } from './hooks/use-validation-banner'
41+
import { logger } from './utils/logger'
42+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
3943
import { useChatStore } from './state/chat-store'
4044
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
4145
import { formatQueuedPreview } from './utils/helpers'
@@ -126,6 +130,7 @@ export const Chat = ({
126130
setLastMessageMode,
127131
addSessionCredits,
128132
resetChatStore,
133+
sessionCreditsUsed,
129134
} = useChatStore(
130135
useShallow((store) => ({
131136
inputValue: store.inputValue,
@@ -161,6 +166,7 @@ export const Chat = ({
161166
setLastMessageMode: store.setLastMessageMode,
162167
addSessionCredits: store.addSessionCredits,
163168
resetChatStore: store.reset,
169+
sessionCreditsUsed: store.sessionCreditsUsed,
164170
})),
165171
)
166172

@@ -624,6 +630,33 @@ export const Chat = ({
624630
/>
625631
)
626632

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+
641+
// Ctrl+F to open feedback for latest completed AI message
642+
useKeyboard(
643+
useCallback(
644+
(key) => {
645+
if (key?.ctrl && key.name === 'f') {
646+
if ('preventDefault' in key && typeof key.preventDefault === 'function') {
647+
key.preventDefault()
648+
}
649+
const latest = [...messages].reverse().find((m) => m.variant === 'ai' && m.isComplete)
650+
if (latest) {
651+
setFeedbackMessageId(latest.id)
652+
setIsFeedbackOpen(true)
653+
}
654+
}
655+
},
656+
[messages],
657+
),
658+
)
659+
627660
const validationBanner = useValidationBanner({
628661
liveValidationErrors: validationErrors,
629662
loadedAgentsData,
@@ -699,6 +732,7 @@ export const Chat = ({
699732
onToggleCollapsed={handleCollapseToggle}
700733
onBuildFast={handleBuildFast}
701734
onBuildMax={handleBuildMax}
735+
onFeedback={openFeedbackForMessage}
702736
/>
703737
)
704738
})}
@@ -918,6 +952,41 @@ export const Chat = ({
918952
hasInvalidCredentials={hasInvalidCredentials}
919953
/>
920954
)}
955+
956+
{isFeedbackOpen && (
957+
<FeedbackModal
958+
open={isFeedbackOpen}
959+
message={messages.find((m) => m.id === feedbackMessageId) ?? null}
960+
onClose={() => setIsFeedbackOpen(false)}
961+
onSubmit={(text) => {
962+
const target = messages.find((m) => m.id === feedbackMessageId)
963+
const recent = messages.slice(Math.max(0, messages.length - 5)).map((m) => ({
964+
id: m.id,
965+
variant: m.variant,
966+
timestamp: m.timestamp,
967+
hasBlocks: !!m.blocks,
968+
contentPreview: (m.content || '').slice(0, 400),
969+
}))
970+
logger.info(
971+
{
972+
eventId: AnalyticsEvent.FEEDBACK_SUBMITTED,
973+
source: 'cli',
974+
messageId: target?.id,
975+
variant: target?.variant,
976+
completionTime: target?.completionTime,
977+
credits: target?.credits,
978+
agentMode,
979+
sessionCreditsUsed,
980+
feedbackText: text,
981+
runState: target?.metadata?.runState,
982+
recentMessages: recent,
983+
},
984+
'User submitted feedback',
985+
)
986+
setIsFeedbackOpen(false)
987+
}}
988+
/>
989+
)}
921990
</box>
922991
)
923992
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React, { useRef } from 'react'
2+
3+
import { useHoverToggle } from './agent-mode-toggle'
4+
import { Button } from './button'
5+
import { useTheme } from '../hooks/use-theme'
6+
import { BORDER_CHARS } from '../utils/ui-constants'
7+
import { logger } from '../utils/logger'
8+
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
9+
10+
interface FeedbackIconButtonProps {
11+
onClick?: () => void
12+
messageId?: string
13+
}
14+
15+
export const FeedbackIconButton: React.FC<FeedbackIconButtonProps> = ({ onClick, messageId }) => {
16+
const theme = useTheme()
17+
const hover = useHoverToggle()
18+
const hoveredOnceRef = useRef(false)
19+
20+
const handleMouseOver = () => {
21+
hover.clearCloseTimer()
22+
hover.scheduleOpen()
23+
if (!hoveredOnceRef.current) {
24+
hoveredOnceRef.current = true
25+
logger.info(
26+
{
27+
eventId: AnalyticsEvent.FEEDBACK_BUTTON_HOVERED,
28+
messageId,
29+
source: 'cli',
30+
},
31+
'Feedback button hovered',
32+
)
33+
}
34+
}
35+
const handleMouseOut = () => hover.scheduleClose()
36+
37+
const textCollapsed = '[?]'
38+
const textExpanded = '[share feedback]'
39+
40+
return (
41+
<Button
42+
style={{
43+
flexDirection: 'row',
44+
alignItems: 'center',
45+
paddingLeft: 1,
46+
paddingRight: 1,
47+
borderStyle: 'single',
48+
borderColor: hover.isOpen ? theme.foreground : theme.border,
49+
customBorderChars: BORDER_CHARS,
50+
}}
51+
onClick={() => onClick?.()}
52+
onMouseOver={handleMouseOver}
53+
onMouseOut={handleMouseOut}
54+
>
55+
<text style={{ wrapMode: 'none', fg: theme.foreground }}>
56+
{hover.isOpen ? textExpanded : textCollapsed}
57+
</text>
58+
</Button>
59+
)
60+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import React, { useCallback, useMemo, useRef, useState } from 'react'
2+
import { useRenderer } from '@opentui/react'
3+
4+
import { MultilineInput, type MultilineInputHandle } from './multiline-input'
5+
import { Button } from './button'
6+
import { useTheme } from '../hooks/use-theme'
7+
import type { ChatMessage } from '../types/chat'
8+
9+
interface FeedbackModalProps {
10+
open: boolean
11+
message: ChatMessage | null
12+
onClose: () => void
13+
onSubmit: (text: string) => void
14+
}
15+
16+
export const FeedbackModal: React.FC<FeedbackModalProps> = ({ open, message, onClose, onSubmit }) => {
17+
const theme = useTheme()
18+
const renderer = useRenderer()
19+
const [value, setValue] = useState('')
20+
const [cursorPosition, setCursorPosition] = useState(0)
21+
const [showDetails, setShowDetails] = useState(false)
22+
const inputRef = useRef<MultilineInputHandle | null>(null)
23+
24+
const terminalWidth = renderer?.width || 80
25+
const terminalHeight = renderer?.height || 24
26+
27+
const modalWidth = Math.max(60, Math.min(terminalWidth - 4, 100))
28+
const modalHeight = Math.max(12, Math.min(terminalHeight - 4, 24))
29+
const modalLeft = Math.floor((terminalWidth - modalWidth) / 2)
30+
const modalTop = Math.floor((terminalHeight - modalHeight) / 2)
31+
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+
46+
const handleSubmit = useCallback(() => {
47+
const text = value.trim()
48+
if (text.length === 0) return
49+
onSubmit(text)
50+
setValue('')
51+
}, [onSubmit, value])
52+
53+
if (!open) return null
54+
55+
return (
56+
<box
57+
position="absolute"
58+
left={modalLeft}
59+
top={modalTop}
60+
border
61+
borderStyle="double"
62+
borderColor={theme.primary}
63+
style={{
64+
width: modalWidth,
65+
height: modalHeight,
66+
backgroundColor: theme.surface,
67+
padding: 1,
68+
flexDirection: 'column',
69+
gap: 1,
70+
}}
71+
>
72+
<text style={{ wrapMode: 'none' }}>
73+
<span fg={theme.primary}>Share Feedback</span>
74+
</text>
75+
76+
<text style={{ wrapMode: 'word' }}>
77+
<span fg={theme.secondary}>Thanks for helping us improve! What happened?</span>
78+
</text>
79+
80+
<box style={{ flexDirection: 'column' }}>
81+
<MultilineInput
82+
value={value}
83+
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)
87+
}}
88+
onSubmit={handleSubmit}
89+
placeholder={'Tell us more...'}
90+
focused={true}
91+
maxHeight={6}
92+
width={modalWidth - 4}
93+
textAttributes={undefined}
94+
ref={inputRef}
95+
cursorPosition={cursorPosition}
96+
/>
97+
</box>
98+
99+
<box style={{ flexDirection: 'row', gap: 2, alignItems: 'center' }}>
100+
<text style={{ wrapMode: 'none' }}>
101+
<span fg={theme.muted}>Auto-attached: Message content • Trace data • Session info</span>
102+
</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}>
125+
<text style={{ wrapMode: 'none' }}>
126+
<span fg={theme.success}>Submit</span>
127+
</text>
128+
</Button>
129+
</box>
130+
</box>
131+
)
132+
}

cli/src/components/message-block.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, { memo, useCallback, type ReactNode } from 'react'
33

44
import { AgentBranchItem } from './agent-branch-item'
55
import { ElapsedTimer } from './elapsed-timer'
6+
import { FeedbackIconButton } from './feedback-icon-button'
67
import { useTheme } from '../hooks/use-theme'
78
import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update'
89
import { isTextBlock, isToolBlock } from '../types/chat'
@@ -72,6 +73,7 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => {
7273
onBuildMax,
7374
setCollapsedAgents,
7475
addAutoCollapsedAgent,
76+
onFeedback,
7577
} = props
7678
useWhyDidYouUpdateById('MessageBlock', messageId, props, {
7779
logLevel: 'debug',
@@ -149,19 +151,28 @@ export const MessageBlock = memo((props: MessageBlockProps): ReactNode => {
149151
</text>
150152
)}
151153
{isComplete && (
152-
<text
153-
attributes={TextAttributes.DIM}
154+
<box
154155
style={{
155-
wrapMode: 'none',
156-
fg: theme.secondary,
157-
marginTop: 0,
158-
marginBottom: 0,
156+
flexDirection: 'row',
157+
alignItems: 'center',
159158
alignSelf: 'flex-end',
159+
gap: 1,
160160
}}
161161
>
162-
{completionTime}
163-
{credits && ` • ${credits} credits`}
164-
</text>
162+
<text
163+
attributes={TextAttributes.DIM}
164+
style={{
165+
wrapMode: 'none',
166+
fg: theme.secondary,
167+
marginTop: 0,
168+
marginBottom: 0,
169+
}}
170+
>
171+
{completionTime}
172+
{credits && ` • ${credits} credits`}
173+
</text>
174+
<FeedbackIconButton onClick={() => onFeedback?.(messageId)} messageId={messageId} />
175+
</box>
165176
)}
166177
</>
167178
)}
@@ -237,6 +248,7 @@ interface MessageBlockProps {
237248
onBuildMax: () => void
238249
setCollapsedAgents: (value: (prev: Set<string>) => Set<string>) => void
239250
addAutoCollapsedAgent: (value: string) => void
251+
onFeedback?: (messageId: string) => void
240252
}
241253

242254
interface AgentBodyProps {

0 commit comments

Comments
 (0)