Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/components/file-browser/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
)}

{showSaveButton && (
<Button variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); shouldVirtualize ? handleVirtualizedSaveClick() : handleSave() }} disabled={isSaving || (shouldVirtualize && !hasVirtualizedChanges)} className="border-green-600 bg-green-600/10 text-green-600 hover:bg-green-600/20 h-7 w-7 p-0">
<Button variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); if (shouldVirtualize) { handleVirtualizedSaveClick(); } else { handleSave(); } }} disabled={isSaving || (shouldVirtualize && !hasVirtualizedChanges)} className="border-green-600 bg-green-600/10 text-green-600 hover:bg-green-600/20 h-7 w-7 p-0">
<Save className="w-3 h-3" />
</Button>
)}
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/message/MessagePart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { Volume2, Square, Loader2 } from 'lucide-react'
import { TextPart } from './TextPart'
import { PatchPart } from './PatchPart'
import { ToolCallPart } from './ToolCallPart'
import { RetryPart } from './RetryPart'
import { useTTS } from '@/hooks/useTTS'
import { CopyButton } from '@/components/ui/copy-button'

type RetryPartType = components['schemas']['RetryPart']

type Part = components['schemas']['Part']

interface MessagePartProps {
Expand Down Expand Up @@ -152,6 +155,8 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par
<span className="font-medium">{part.filename || 'File'}</span>
</span>
)
case 'retry':
return <RetryPart part={part as RetryPartType} />
default:
return
}
Expand Down
69 changes: 38 additions & 31 deletions frontend/src/components/message/MessageThread.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo } from 'react'
import { memo, useMemo } from 'react'
import { MessagePart } from './MessagePart'
import type { MessageWithParts } from '@/api/types'

Expand All @@ -24,12 +24,24 @@ const isMessageStreaming = (msg: MessageWithParts): boolean => {
return !('completed' in msg.info.time && msg.info.time.completed)
}

const isMessageThinking = (msg: MessageWithParts): boolean => {
if (msg.info.role !== 'assistant') return false
return msg.parts.length === 0 && isMessageStreaming(msg)


const findPendingAssistantMessageId = (messages: MessageWithParts[]): string | undefined => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role === 'assistant' && isMessageStreaming(msg)) {
return msg.info.id
}
}
return undefined
}

export const MessageThread = memo(function MessageThread({ messages, onFileClick, onChildSessionClick }: MessageThreadProps) {
const pendingAssistantId = useMemo(() => {
if (!messages) return undefined
return findPendingAssistantMessageId(messages)
}, [messages])

if (!messages || messages.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Expand All @@ -42,7 +54,7 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick
<div className="flex flex-col space-y-2 p-2 overflow-x-hidden">
{messages.map((msg) => {
const streaming = isMessageStreaming(msg)
const thinking = isMessageThinking(msg)
const isQueued = msg.info.role === 'user' && pendingAssistantId && msg.info.id > pendingAssistantId

return (
<div
Expand All @@ -52,7 +64,9 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick
<div
className={`w-full rounded-lg p-1.5 ${
msg.info.role === 'user'
? 'bg-blue-600/20 border border-blue-600/30'
? isQueued
? 'bg-amber-500/10 border border-amber-500/30'
: 'bg-blue-600/20 border border-blue-600/30'
: 'bg-card/50 border border-border'
} ${streaming ? 'animate-pulse-subtle' : ''}`}
>
Expand All @@ -65,35 +79,28 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick
{new Date(msg.info.time.created).toLocaleTimeString()}
</span>
)}
{streaming && (
<span className="text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1">
<span className="animate-pulse">●</span> <span className="shine-loading">Generating...</span>
{isQueued && (
<span className="text-xs font-semibold bg-amber-500 text-amber-950 px-1.5 py-0.5 rounded">
QUEUED
</span>
)}
</div>

{thinking ? (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="animate-pulse">▋</span>
<span className="text-sm shine-loading">Thinking...</span>
</div>
) : (
<div className="space-y-2">
{msg.parts.map((part, index) => (
<div key={`${msg.info.id}-${part.id}-${index}`}>
<MessagePart
part={part}
role={msg.info.role}
allParts={msg.parts}
partIndex={index}
onFileClick={onFileClick}
onChildSessionClick={onChildSessionClick}
messageTextContent={msg.info.role === 'assistant' ? getMessageTextContent(msg) : undefined}
/>
</div>
))}
</div>
)}
<div className="space-y-2">
{msg.parts.map((part, index) => (
<div key={`${msg.info.id}-${part.id}-${index}`}>
<MessagePart
part={part}
role={msg.info.role}
allParts={msg.parts}
partIndex={index}
onFileClick={onFileClick}
onChildSessionClick={onChildSessionClick}
messageTextContent={msg.info.role === 'assistant' ? getMessageTextContent(msg) : undefined}
/>
</div>
))}
</div>
</div>
</div>
)
Expand Down
54 changes: 41 additions & 13 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ChevronDown } from 'lucide-react'

import { CommandSuggestions } from '@/components/command/CommandSuggestions'
import { MentionSuggestions, type MentionItem } from './MentionSuggestions'
import { SessionStatusIndicator } from '@/components/ui/session-status-indicator'
import { detectMentionTrigger, parsePromptToParts, getFilename, filterAgentsByQuery } from '@/lib/promptParser'
import { getModel, formatModelName } from '@/api/providers'
import type { components } from '@/api/opencode-types'
Expand Down Expand Up @@ -119,6 +120,23 @@ export function PromptInput({

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

if (hasActiveStream) {
const parts = parsePromptToParts(prompt, attachedFiles)
sendPrompt.mutate({
sessionID,
parts,
model: currentModel,
agent: selectedAgent || currentMode
})
setPrompt('')
setAttachedFiles(new Map())
setSelectedAgent(null)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
return
}

if (isBashMode) {
const command = prompt.startsWith('!') ? prompt.slice(1) : prompt
Expand Down Expand Up @@ -454,16 +472,20 @@ export function PromptInput({
}, [selectedModel, currentModel])

useEffect(() => {
if (textareaRef.current && !disabled && !hasActiveStream) {
if (textareaRef.current && !disabled) {
textareaRef.current.focus()
}
}, [disabled, hasActiveStream])
}, [disabled])



return (
<div className="relative backdrop-blur-md bg-background opacity-95 border border-border rounded-xl p-2 md:p-3 mx-2 md:mx-4 mb-2 md:mb-5 w-[90%] md:max-w-4xl">

{hasActiveStream && (
<div className="mb-2">
<SessionStatusIndicator sessionID={sessionID} />
</div>
)}

<textarea
ref={textareaRef}
Expand All @@ -475,7 +497,7 @@ export function PromptInput({
? "Enter bash command..."
: "Send a message..."
}
disabled={disabled || hasActiveStream}
disabled={disabled}
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-background resize-none min-h-[40px] max-h-[120px] disabled:opacity-50 disabled:cursor-not-allowed md:text-sm rounded-lg ${
isBashMode
? 'border-purple-500/50 bg-purple-500/5 focus:bg-background'
Expand Down Expand Up @@ -537,18 +559,24 @@ export function PromptInput({
<ChevronDown className="w-5 h-5" />
</button>
)}
{hasActiveStream && (
<button
onClick={handleStop}
disabled={disabled}
className="px-3 md:px-4 py-1.5 rounded-lg text-sm font-medium transition-colors bg-destructive hover:bg-destructive/90 text-destructive-foreground"
title="Stop"
>
Stop
</button>
)}
<button
data-submit-prompt
onClick={hasActiveStream ? handleStop : handleSubmit}
disabled={(!prompt.trim() && !hasActiveStream) || disabled}
className={`px-5 md:px-6 py-1.5 rounded-lg text-sm font-medium transition-colors ${
hasActiveStream
? 'bg-destructive hover:bg-destructive/90 text-destructive-foreground'
: 'bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground'
}`}
title={hasActiveStream ? 'Stop' : 'Send'}
onClick={handleSubmit}
disabled={!prompt.trim() || disabled}
className="px-5 md:px-6 py-1.5 rounded-lg text-sm font-medium transition-colors bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground"
title={hasActiveStream ? 'Queue message' : 'Send'}
>
{hasActiveStream ? 'Stop' : 'Send'}
{hasActiveStream ? 'Queue' : 'Send'}
</button>
</div>
</div>
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/components/message/RetryPart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { memo, useState, useEffect } from 'react'
import type { components } from '@/api/opencode-types'
import { RefreshCw, AlertTriangle } from 'lucide-react'

type RetryPartType = components['schemas']['RetryPart']

interface RetryPartProps {
part: RetryPartType
}

export const RetryPart = memo(function RetryPart({ part }: RetryPartProps) {
const [countdown, setCountdown] = useState(5)

useEffect(() => {
if (countdown <= 0) return

const timer = setInterval(() => {
setCountdown(prev => Math.max(0, prev - 1))
}, 1000)

return () => clearInterval(timer)
}, [countdown])

const errorMessage = part.error?.data?.message || 'An error occurred'

return (
<div className="flex items-center gap-3 p-3 my-2 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex-shrink-0">
<div className="relative">
<RefreshCw className="w-5 h-5 text-amber-500 animate-spin" style={{ animationDuration: '2s' }} />
<AlertTriangle className="w-3 h-3 text-amber-600 absolute -bottom-0.5 -right-0.5" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">
Retry attempt {part.attempt}
</span>
{countdown > 0 && (
<span className="text-xs text-amber-500/80">
(retrying in {countdown}s)
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{errorMessage}
</p>
</div>
</div>
)
})
2 changes: 1 addition & 1 deletion frontend/src/components/message/TextPart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function CodeBlock({ children, className, ...props }: CodeBlockProps) {
if (typeof node === 'number') return node.toString()
if (Array.isArray(node)) return node.map(extractTextContent).join('')
if (React.isValidElement(node)) {
const element = node as React.ReactElement<any, any>
const element = node as React.ReactElement<Record<string, unknown>>
if (element.props.children) {
return extractTextContent(element.props.children as React.ReactNode)
}
Expand Down
42 changes: 35 additions & 7 deletions frontend/src/components/message/ToolCallPart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { components } from '@/api/opencode-types'
import { useSettings } from '@/hooks/useSettings'
import { useUserBash } from '@/stores/userBashStore'
import { detectFileReferences } from '@/lib/fileReferences'
import { ExternalLink } from 'lucide-react'
import { ExternalLink, Loader2 } from 'lucide-react'
import { CopyButton } from '@/components/ui/copy-button'

type ToolPart = components['schemas']['ToolPart']
Expand Down Expand Up @@ -87,13 +87,15 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
const getStatusIcon = () => {
switch (part.state.status) {
case 'completed':
return '✓'
return <span>✓</span>
case 'error':
return '✗'
return <span>✗</span>
case 'running':
return '⟳'
return <Loader2 className="w-3.5 h-3.5 animate-spin" />
case 'pending':
return <span className="inline-block w-2 h-2 rounded-full bg-current animate-pulse" />
default:
return '○'
return <span>○</span>
}
}

Expand Down Expand Up @@ -150,8 +152,23 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
)
}

const getBorderStyle = () => {
switch (part.state.status) {
case 'running':
return 'border-yellow-500/50 shadow-sm shadow-yellow-500/10'
case 'pending':
return 'border-blue-500/30'
case 'error':
return 'border-red-500/30'
case 'completed':
return 'border-border'
default:
return 'border-border'
}
}

return (
<div className="border border-border rounded-lg overflow-hidden my-2">
<div className={`border rounded-lg overflow-hidden my-2 transition-all ${getBorderStyle()}`}>
<button
onClick={() => setExpanded(!expanded)}
className="w-full px-4 py-2 bg-card hover:bg-card-hover text-left flex items-center gap-2 text-sm min-w-0"
Expand Down Expand Up @@ -194,7 +211,18 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
</button>

{expanded && (
<div className="bg-card space-y-2">
<div className="bg-card space-y-2 p-3">
{part.state.status === 'pending' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span>Preparing tool call...</span>
</div>
)}

{part.state.status === 'running' && (
<div className="text-sm">
<div className="text-zinc-400 mb-1">Input:</div>
Expand Down
Loading