Skip to content

Commit 64e49a8

Browse files
author
Chris Scott
committed
Improve chat UI with scroll management and bash command handling
1 parent 8e955f7 commit 64e49a8

9 files changed

Lines changed: 353 additions & 149 deletions

File tree

frontend/src/components/file-browser/FileOperations.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate
4444
<Button variant="outline" size="sm" asChild>
4545
<label htmlFor="file-upload" className="cursor-pointer flex items-center gap-2">
4646
<Upload className="w-4 h-4" />
47-
Upload
47+
<span className='hidden sm:inline'>Upload</span>
4848
</label>
4949
</Button>
5050
</div>
@@ -103,4 +103,4 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate
103103
</Dialog>
104104
</div>
105105
)
106-
})
106+
})

frontend/src/components/message/MessagePart.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { memo } from 'react'
12
import type { components } from '@/api/opencode-types'
23
import { Copy } from 'lucide-react'
34
import { TextPart } from './TextPart'
@@ -33,8 +34,6 @@ function getCopyableContent(part: Part, allParts?: Part[]): string {
3334
return part.snapshot || ''
3435
case 'agent':
3536
return `Agent: ${part.name}`
36-
case 'step-start':
37-
return 'Starting step...'
3837
case 'step-finish':
3938
if (allParts) {
4039
return allParts
@@ -77,7 +76,7 @@ function CopyButton({ content, title, className = "" }: { content: string; title
7776

7877

7978

80-
export function MessagePart({ part, role, allParts, partIndex, onFileClick }: MessagePartProps) {
79+
export const MessagePart = memo(function MessagePart({ part, role, allParts, partIndex, onFileClick }: MessagePartProps) {
8180
const copyableContent = getCopyableContent(part, allParts)
8281

8382
switch (part.type) {
@@ -116,16 +115,10 @@ export function MessagePart({ part, role, allParts, partIndex, onFileClick }: Me
116115
<div className="text-sm font-medium text-blue-400">Agent: {part.name}</div>
117116
</div>
118117
)
119-
case 'step-start':
120-
return (
121-
<div className="text-xs text-muted-foreground my-1">
122-
→ Starting step...
123-
</div>
124-
)
125118
case 'step-finish':
126119
return (
127120
<div className="text-xs text-muted-foreground my-1 flex items-center gap-2">
128-
<span>✓ Step complete • ${part.cost.toFixed(4)}{part.tokens.input + part.tokens.output} tokens</span>
121+
<span>${part.cost.toFixed(4)}{part.tokens.input + part.tokens.output} tokens</span>
129122
<CopyButton content={copyableContent} title="Copy step complete" />
130123
</div>
131124
)
@@ -137,10 +130,6 @@ export function MessagePart({ part, role, allParts, partIndex, onFileClick }: Me
137130
</span>
138131
)
139132
default:
140-
return (
141-
<div className="border border-zinc-800 rounded-lg p-4 my-2 bg-zinc-950">
142-
<div className="text-xs text-zinc-500">Unknown part type</div>
143-
</div>
144-
)
133+
return
145134
}
146-
}
135+
})

frontend/src/components/message/MessageThread.tsx

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useRef, useEffect } from 'react'
1+
import { memo, useRef, useEffect, useCallback } from 'react'
22
import { useMessages } from '@/hooks/useOpenCode'
33
import { useSettings } from '@/hooks/useSettings'
44
import { MessagePart } from './MessagePart'
@@ -10,6 +10,7 @@ interface MessageThreadProps {
1010
directory?: string
1111
onFileClick?: (filePath: string, lineNumber?: number) => void
1212
containerRef?: React.RefObject<HTMLDivElement | null>
13+
onScrollStateChange?: (isScrolledUp: boolean) => void
1314
}
1415

1516
const isMessageStreaming = (msg: MessageWithParts): boolean => {
@@ -22,23 +23,68 @@ const isMessageThinking = (msg: MessageWithParts): boolean => {
2223
return msg.parts.length === 0 && isMessageStreaming(msg)
2324
}
2425

25-
export const MessageThread = memo(function MessageThread({ opcodeUrl, sessionID, directory, onFileClick, containerRef }: MessageThreadProps) {
26+
const SCROLL_THRESHOLD = 150
27+
const SCROLL_DEBOUNCE_MS = 50
28+
29+
export const MessageThread = memo(function MessageThread({ opcodeUrl, sessionID, directory, onFileClick, containerRef, onScrollStateChange }: MessageThreadProps) {
2630
const { data: messages, isLoading, error } = useMessages(opcodeUrl, sessionID, directory)
2731
const { preferences } = useSettings()
2832
const lastMessageCountRef = useRef(0)
29-
const userJustSentMessageRef = useRef(false)
33+
const userScrolledUpRef = useRef(false)
3034
const hasInitialScrolledRef = useRef(false)
35+
const scrollRAFRef = useRef<number | null>(null)
36+
const lastScrollTimeRef = useRef(0)
37+
38+
const scrollToBottom = useCallback((force = false) => {
39+
if (!containerRef?.current) return
40+
41+
const now = Date.now()
42+
if (!force && now - lastScrollTimeRef.current < SCROLL_DEBOUNCE_MS) {
43+
return
44+
}
45+
lastScrollTimeRef.current = now
46+
47+
if (scrollRAFRef.current) {
48+
cancelAnimationFrame(scrollRAFRef.current)
49+
}
50+
51+
scrollRAFRef.current = requestAnimationFrame(() => {
52+
if (containerRef?.current) {
53+
containerRef.current.scrollTop = containerRef.current.scrollHeight
54+
userScrolledUpRef.current = false
55+
onScrollStateChange?.(false)
56+
}
57+
scrollRAFRef.current = null
58+
})
59+
}, [containerRef, onScrollStateChange])
60+
61+
useEffect(() => {
62+
if (!containerRef?.current) return
63+
64+
const container = containerRef.current
65+
66+
const handleScroll = () => {
67+
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
68+
const isScrolledUp = distanceFromBottom > SCROLL_THRESHOLD
69+
if (userScrolledUpRef.current !== isScrolledUp) {
70+
userScrolledUpRef.current = isScrolledUp
71+
onScrollStateChange?.(isScrolledUp)
72+
}
73+
}
74+
75+
container.addEventListener('scroll', handleScroll, { passive: true })
76+
return () => container.removeEventListener('scroll', handleScroll)
77+
}, [containerRef, onScrollStateChange])
3178

3279
useEffect(() => {
3380
if (!containerRef?.current || !messages) return
3481

35-
const container = containerRef.current
3682
const currentMessageCount = messages.length
3783
const previousMessageCount = lastMessageCountRef.current
3884

3985
if (!hasInitialScrolledRef.current && currentMessageCount > 0) {
4086
hasInitialScrolledRef.current = true
41-
container.scrollTop = container.scrollHeight
87+
scrollToBottom(true)
4288
lastMessageCountRef.current = currentMessageCount
4389
return
4490
}
@@ -50,28 +96,25 @@ export const MessageThread = memo(function MessageThread({ opcodeUrl, sessionID,
5096
const isUserMessage = lastMessage?.info.role === 'user'
5197

5298
if (messageAdded && isUserMessage) {
53-
userJustSentMessageRef.current = true
54-
container.scrollTop = container.scrollHeight
99+
userScrolledUpRef.current = false
100+
scrollToBottom(true)
55101
return
56102
}
57103

58104
if (!preferences?.autoScroll) return
59105

60-
const isNearBottom =
61-
container.scrollHeight - container.scrollTop - container.clientHeight < 100
62-
63-
if (userJustSentMessageRef.current || isNearBottom) {
64-
container.scrollTop = container.scrollHeight
106+
if (!userScrolledUpRef.current) {
107+
scrollToBottom()
65108
}
109+
}, [messages, preferences?.autoScroll, containerRef, scrollToBottom])
66110

67-
if (
68-
lastMessage?.info.role === 'assistant' &&
69-
'completed' in lastMessage.info.time &&
70-
lastMessage.info.time.completed
71-
) {
72-
userJustSentMessageRef.current = false
111+
useEffect(() => {
112+
return () => {
113+
if (scrollRAFRef.current) {
114+
cancelAnimationFrame(scrollRAFRef.current)
115+
}
73116
}
74-
}, [messages, preferences?.autoScroll, containerRef])
117+
}, [])
75118

76119
if (isLoading) {
77120
return (
@@ -98,7 +141,7 @@ export const MessageThread = memo(function MessageThread({ opcodeUrl, sessionID,
98141
}
99142

100143
return (
101-
<div className="flex flex-col space-y-2 p-4 overflow-x-hidden">
144+
<div className="flex flex-col space-y-2 p-2 overflow-x-hidden">
102145
{messages.map((msg) => {
103146
const streaming = isMessageStreaming(msg)
104147
const thinking = isMessageThinking(msg)
@@ -109,7 +152,7 @@ export const MessageThread = memo(function MessageThread({ opcodeUrl, sessionID,
109152
className="flex flex-col"
110153
>
111154
<div
112-
className={`w-full rounded-lg p-2 ${
155+
className={`w-full rounded-lg p-1.5 ${
113156
msg.info.role === 'user'
114157
? 'bg-blue-600/20 border border-blue-600/30'
115158
: 'bg-card/50 border border-border'

frontend/src/components/message/PromptInput.tsx

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useCommands } from '@/hooks/useCommands'
55
import { useCommandHandler } from '@/hooks/useCommandHandler'
66
import { useFileSearch } from '@/hooks/useFileSearch'
77
import { useStandalone } from '@/hooks/useStandalone'
8+
import { useUserBash } from '@/stores/userBashStore'
9+
import { ChevronDown } from 'lucide-react'
810

911
import { CommandSuggestions } from '@/components/command/CommandSuggestions'
1012
import { FileSuggestions } from './FileSuggestions'
@@ -26,6 +28,8 @@ interface PromptInputProps {
2628
directory?: string
2729
sessionID: string
2830
disabled?: boolean
31+
showScrollButton?: boolean
32+
onScrollToBottom?: () => void
2933
onShowSessionsDialog?: () => void
3034
onShowModelsDialog?: () => void
3135
onShowHelpDialog?: () => void
@@ -35,7 +39,9 @@ export function PromptInput({
3539
opcodeUrl,
3640
directory,
3741
sessionID,
38-
disabled,
42+
disabled,
43+
showScrollButton,
44+
onScrollToBottom,
3945
onShowSessionsDialog,
4046
onShowModelsDialog,
4147
onShowHelpDialog
@@ -79,12 +85,14 @@ export function PromptInput({
7985
)
8086

8187
const isStandalone = useStandalone()
88+
const { addUserBashCommand } = useUserBash()
8289

8390
const handleSubmit = () => {
8491
if (!prompt.trim() || disabled) return
8592

8693
if (isBashMode) {
8794
const command = prompt.startsWith('!') ? prompt.slice(1) : prompt
95+
addUserBashCommand(command)
8896
sendShell.mutate({
8997
sessionID,
9098
command,
@@ -418,7 +426,7 @@ export function PromptInput({
418426

419427

420428
return (
421-
<div className={`backdrop-blur-md bg-background/90 border border-border rounded-lg p-1 md:p-3 mx-2 md:mx-4 md:mb-4 max-w-4xl md:mx-auto pb-safe ${isStandalone ? 'mb-6' : 'mb-2'}`}>
429+
<div className={`backdrop-blur-md bg-background opacity-95 border border-border rounded-lg p-2 md:p-3 mx-2 md:mx-4 md:mb-4 w-full max-w-4xl pb-safe ${isStandalone ? 'mb-6' : 'mb-2'}`}>
422430

423431

424432
<textarea
@@ -428,43 +436,40 @@ export function PromptInput({
428436
onKeyDown={handleKeyDown}
429437
placeholder={
430438
isBashMode
431-
? "Enter bash command... (Esc to exit)"
432-
: "Send a message... (Cmd/Ctrl+Enter)"
439+
? "Enter bash command..."
440+
: "Send a message..."
433441
}
434442
disabled={disabled || hasActiveStream}
435-
className={`w-full bg-background px-3 text-[16px] text-foreground placeholder-muted-foreground focus:outline-none resize-none min-h-[36px] max-h-[120px] disabled:opacity-50 disabled:cursor-not-allowed md:text-sm rounded-lg ${
443+
className={`w-full bg-background/90 px-2 md:px-3 py-2 text-[16px] text-foreground placeholder-muted-foreground focus:outline-none focus:bg-black resize-none min-h-[40px] max-h-[120px] disabled:opacity-50 disabled:cursor-not-allowed md:text-sm rounded-lg ${
436444
isBashMode
437-
? 'border-purple-500/50 bg-purple-500/5'
445+
? 'border-purple-500/50 bg-purple-500/5 focus:bg-black'
438446
: ''
439447
}`}
440448
rows={1}
441449
/>
442450

443-
<div className="flex gap-2 items-center justify-between">
444-
<div className="flex gap-2 items-center">
451+
<div className="flex gap-1.5 md:gap-2 items-center justify-between mb-1">
452+
<div className="flex gap-1.5 md:gap-2 items-center">
445453
<button
446454
onClick={handleModeToggle}
447-
className={`w-16 px-2 py-1 rounded-md text-xs font-medium border ${modeBg} ${modeColor} hover:opacity-80 transition-opacity cursor-pointer`}
455+
className={`px-2 py-1 rounded-md text-xs font-medium border w-14 ${
456+
isBashMode
457+
? 'bg-purple-500/10 border-purple-500/30 text-purple-600 dark:text-purple-400'
458+
: `${modeBg} ${modeColor}`
459+
} hover:opacity-80 transition-opacity cursor-pointer`}
448460
>
449-
{currentMode.toUpperCase()}
461+
{isBashMode ? 'BASH' : currentMode.toUpperCase()}
462+
</button>
463+
<button
464+
onClick={onShowModelsDialog}
465+
className="px-2 py-1 rounded-md text-xs font-medium border bg-muted border-border text-muted-foreground hover:bg-muted-foreground/10 transition-colors cursor-pointer max-w-[120px] md:max-w-[180px] truncate"
466+
>
467+
{modelName.length > 12 ? modelName.substring(0, 10) + '...' : modelName || 'Select model'}
450468
</button>
451-
{isBashMode && (
452-
<div className="px-2 py-1 rounded-md text-xs font-medium border bg-purple-500/10 border-purple-500/30 text-purple-600 dark:text-purple-400">
453-
BASH MODE
454-
</div>
455-
)}
456-
{modelName && (
457-
<button
458-
onClick={onShowModelsDialog}
459-
className="px-2 py-1 rounded-md text-xs font-medium border bg-muted border-border text-muted-foreground hover:bg-muted-foreground/10 transition-colors cursor-pointer"
460-
>
461-
{modelName.length > 20 ? `${modelName.slice(0, 20)}...` : modelName}
462-
</button>
463-
)}
464469
<DropdownMenu>
465470
<DropdownMenuTrigger asChild>
466471
<button
467-
className="w-6 h-6 rounded-full border-2 border-foreground text-foreground hover:bg-foreground hover:text-background transition-colors flex items-center justify-center text-sm font-medium"
472+
className="w-6 h-6 rounded-full border-2 border-foreground text-foreground hover:bg-foreground hover:text-background transition-colors flex items-center justify-center text-sm font-medium flex-shrink-0"
468473
title="Help"
469474
>
470475
?
@@ -486,19 +491,30 @@ export function PromptInput({
486491
</DropdownMenuContent>
487492
</DropdownMenu>
488493
</div>
489-
<button
490-
data-submit-prompt
491-
onClick={hasActiveStream ? handleStop : handleSubmit}
492-
disabled={(!prompt.trim() && !hasActiveStream) || disabled}
493-
className={`px-6 py-1.5 rounded-lg text-sm font-medium transition-colors ${
494-
hasActiveStream
495-
? 'bg-destructive hover:bg-destructive/90 text-destructive-foreground'
496-
: 'bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground'
497-
}`}
498-
title={hasActiveStream ? 'Stop' : 'Send'}
499-
>
500-
{hasActiveStream ? 'Stop' : 'Send'}
501-
</button>
494+
<div className="flex items-center gap-1.5 md:gap-2 flex-shrink-0">
495+
{showScrollButton && (
496+
<button
497+
onClick={onScrollToBottom}
498+
className="p-1.5 md:p-2 rounded-lg bg-muted hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground transition-colors"
499+
title="Scroll to bottom"
500+
>
501+
<ChevronDown className="w-4 h-4" />
502+
</button>
503+
)}
504+
<button
505+
data-submit-prompt
506+
onClick={hasActiveStream ? handleStop : handleSubmit}
507+
disabled={(!prompt.trim() && !hasActiveStream) || disabled}
508+
className={`px-5 md:px-6 py-1.5 rounded-lg text-sm font-medium transition-colors ${
509+
hasActiveStream
510+
? 'bg-destructive hover:bg-destructive/90 text-destructive-foreground'
511+
: 'bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground'
512+
}`}
513+
title={hasActiveStream ? 'Stop' : 'Send'}
514+
>
515+
{hasActiveStream ? 'Stop' : 'Send'}
516+
</button>
517+
</div>
502518
</div>
503519

504520
<CommandSuggestions

0 commit comments

Comments
 (0)