Skip to content
Open
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: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@
"date-fns": "^4.1.0",
"diff": "^8.0.2",
"highlight.js": "^11.11.1",
"i18next": "^26.3.5",
"lucide-react": "^0.546.0",
"mermaid": "^11.12.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"rehype-highlight": "^7.0.2",
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/components/PwaUpdatePrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { useTranslation } from 'react-i18next'
import { useEffect } from 'react'
import { showToast } from '@/lib/toast'
import { onServiceWorkerUpdate, offServiceWorkerUpdate } from '@/lib/serviceWorker'

export function PwaUpdatePrompt() {
const { t } = useTranslation()

useEffect(() => {
onServiceWorkerUpdate(() => {
showToast.info('New build deployed', {
description: 'Refresh to load the latest changes.',
showToast.info(t('pwa.updatePrompt'), {
description: t('pwa.refreshDescription'),
action: {
label: 'Refresh',
label: t('pwa.update'),
onClick: () => window.location.reload(),
},
duration: Infinity,
})
})
return () => offServiceWorkerUpdate()
}, [])
}, [t])

return null
}
10 changes: 6 additions & 4 deletions frontend/src/components/VersionNotifier.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useRef } from 'react'
import { showToast } from '@/lib/toast'
import { useVersionCheck } from '@/hooks/useVersionCheck'

export function VersionNotifier() {
const { t } = useTranslation()
const { data, isSuccess } = useVersionCheck()
const hasNotifiedRef = useRef(false)

Expand All @@ -11,16 +13,16 @@ export function VersionNotifier() {

if (data.updateAvailable && data.latestVersion && data.releaseUrl) {
hasNotifiedRef.current = true
showToast.info(`OpenCode Manager v${data.latestVersion} is available`, {
description: 'A new version is ready to install.',
showToast.info(t('versionNotifier.newVersionInfo', { version: data.latestVersion }), {
description: t('versionNotifier.readyToInstall'),
action: {
label: 'View Release',
label: t('versionNotifier.viewRelease'),
onClick: () => window.open(data.releaseUrl ?? '', '_blank'),
},
duration: 10000,
})
}
}, [isSuccess, data])
}, [isSuccess, data, t])

return null
}
4 changes: 3 additions & 1 deletion frontend/src/components/agent/AgentQuickSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { Check } from 'lucide-react'

Expand Down Expand Up @@ -55,6 +56,7 @@ export function AgentQuickSelect({
onAgentChange,
isBashMode = false,
}: AgentQuickSelectProps) {
const { t } = useTranslation()
const { data: agents = [] } = useAgents(opcodeUrl, directory)

const primaryAgents = useMemo(() => {
Expand All @@ -72,7 +74,7 @@ export function AgentQuickSelect({
const styleVars = isBashMode
? bashStyleVars
: getAgentStyleVars(currentAgent, findAgentColor(agents, currentAgent))
const displayName = isBashMode ? 'Bash' : capitalize(currentAgent)
const displayName = isBashMode ? t('agent.bash') : capitalize(currentAgent)

const buttonContent = (
<button
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/message/CodePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import React from 'react'
import { CopyButton } from '@/components/ui/copy-button'
import { useMobile } from '@/hooks/useMobile'
Expand Down Expand Up @@ -58,6 +59,7 @@ function detectLanguage(fileName: string): string {
}

export function CodePreview({ fileName, content }: CodePreviewProps) {
const { t } = useTranslation()
const isMobile = useMobile()
const [showMore, setShowMore] = React.useState(false)

Expand All @@ -76,7 +78,7 @@ export function CodePreview({ fileName, content }: CodePreviewProps) {
<div className="flex flex-col bg-background">
<div className="px-3 py-2 bg-muted/30 border-b border-border/50 flex items-center justify-between text-xs">
<span className="font-medium truncate flex-1">{extractFileFromPath(fileName)}</span>
<CopyButton content={content} title="Copy" iconSize="sm" variant="ghost" className="flex-shrink-0" />
<CopyButton content={content} title={t('code.copy')} iconSize="sm" variant="ghost" className="flex-shrink-0" />
</div>

<div className={cn('overflow-y-auto', isMobile ? 'max-h-64' : 'max-h-96')}>
Expand All @@ -94,7 +96,7 @@ export function CodePreview({ fileName, content }: CodePreviewProps) {
onClick={() => setShowMore(true)}
className="mx-3 my-2 px-3 py-1.5 bg-muted hover:bg-muted/70 border border-border/50 rounded text-xs text-muted-foreground"
>
Show more ({totalLines - INITIAL_CODE_LINES} more lines)
{t('code.showMore', { count: totalLines - INITIAL_CODE_LINES })}
</button>
)}

Expand All @@ -103,9 +105,9 @@ export function CodePreview({ fileName, content }: CodePreviewProps) {
onClick={() => setShowMore(false)}
className="mx-3 my-2 px-3 py-1.5 bg-muted hover:bg-muted/70 border border-border/50 rounded text-xs text-muted-foreground"
>
Show less
{t('code.showLess')}
</button>
)}
</div>
)
}
}
13 changes: 8 additions & 5 deletions frontend/src/components/message/EditableUserMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { memo, useState, useRef, useEffect } from 'react'
import { Send, X, Pencil, Loader2 } from 'lucide-react'
import { useRefreshMessage } from '@/hooks/useRemoveMessage'
Expand All @@ -24,6 +25,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({
onCancel,
model
}: EditableUserMessageProps) {
const { t } = useTranslation()
const [editedContent, setEditedContent] = useState(content)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isMobile = useMobile()
Expand Down Expand Up @@ -89,13 +91,13 @@ export const EditableUserMessage = memo(function EditableUserMessage({
onKeyDown={handleKeyDown}
onFocus={() => setIsEditingMessage(true)}
className="w-full p-3 rounded-lg bg-background border border-primary/50 focus:border-primary focus:ring-1 focus:ring-primary outline-none resize-none min-h-[60px] text-[16px] md:text-sm"
placeholder="Edit your message..."
placeholder={t('session.editMessagePlaceholder')}
disabled={refreshMessage.isPending}
/>

<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 rounded bg-muted text-xs">Cmd+Enter</kbd> to send, <kbd className="px-1 py-0.5 rounded bg-muted text-xs">Esc</kbd> to cancel
{t('session.editMessageHint')}
</span>

<div className={`flex items-center ${isMobile ? 'gap-3' : 'gap-2'}`}>
Expand All @@ -105,7 +107,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({
className="flex items-center gap-1 px-3 py-1.5 rounded text-sm border border-muted-foreground/40 bg-secondary text-secondary-foreground hover:bg-secondary/80 hover:border-muted-foreground/60 transition-colors disabled:opacity-50"
>
<X className="w-4 h-4" />
Cancel
{t('common.cancel')}
</button>

<button
Expand All @@ -118,7 +120,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({
) : (
<Send className="w-4 h-4" />
)}
Resend
{t('session.resend')}
</button>
</div>
</div>
Expand All @@ -137,6 +139,7 @@ export const ClickableUserMessage = memo(function ClickableUserMessage({
onClick,
isEditable
}: ClickableUserMessageProps) {
const { t } = useTranslation()
const isMobile = useMobile()

if (!isEditable) {
Expand All @@ -151,7 +154,7 @@ export const ClickableUserMessage = memo(function ClickableUserMessage({
<button
onClick={onClick}
className="text-left text-sm whitespace-pre-wrap break-words w-full group/edit hover:bg-blue-600/10 rounded p-1 -m-1 transition-colors flex items-start gap-2"
title="Click to edit and resend"
title={t('session.clickToEdit')}
>
<span className="flex-1">{content}</span>
<Pencil className={`w-4 h-4 flex-shrink-0 mt-0.5 text-muted-foreground transition-all ${isMobile ? 'opacity-100' : 'opacity-50 group-hover/edit:opacity-100 group-hover/edit:text-primary'}`} />
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/components/message/FloatingTTSButton.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useState, useCallback, useRef } from 'react'
import { Volume2, VolumeX } from 'lucide-react'
import { useTTS } from '@/hooks/useTTS'
Expand All @@ -13,6 +14,7 @@ interface FloatingTTSButtonProps {
const LONG_PRESS_DURATION = 500

export function FloatingTTSButton({ messageId, content }: FloatingTTSButtonProps) {
const { t } = useTranslation()
const { speakMessage, stop, isPlaying, isLoading } = useTTS()
const { preferences, updateSettings } = useSettings()

Expand Down Expand Up @@ -40,12 +42,12 @@ export function FloatingTTSButton({ messageId, content }: FloatingTTSButtonProps
didLongPressFireRef.current = true
setIsLongPressVisual(true)
updateSettings({ tts: { ...(preferences?.tts ?? DEFAULT_TTS_CONFIG), autoPlay: nextAutoPlay } })
showToast.info(nextAutoPlay ? 'Auto-play enabled' : 'Auto-play disabled', {
showToast.info(nextAutoPlay ? t('message.autoPlayEnabled') : t('message.autoPlayDisabled'), {
id: 'tts-autoplay-toggle',
duration: 1800,
})
}, LONG_PRESS_DURATION)
}, [autoPlay, updateSettings, preferences?.tts, clearLongPressTimer])
}, [autoPlay, updateSettings, preferences?.tts, clearLongPressTimer, t])

const handlePointerUp = useCallback(() => {
clearLongPressTimer()
Expand Down Expand Up @@ -76,11 +78,11 @@ export function FloatingTTSButton({ messageId, content }: FloatingTTSButtonProps

const showStop = isAnyPlaybackActive
const pillTitle = showStop
? 'Stop playback'
? t('message.stopPlayback')
: hasContent
? 'Play latest reply'
: 'TTS controls'
const pillAriaLabel = `${pillTitle}. ${autoPlay ? 'Auto-play enabled.' : 'Auto-play disabled.'} hold to toggle auto-play`
? t('message.playLatestReply')
: t('message.ttsControls')
const pillAriaLabel = `${pillTitle}. ${autoPlay ? t('message.autoPlayEnabledShort') : t('message.autoPlayDisabledShort')}. ${t('message.holdToToggleAutoPlay')}`
const buttonToneClasses = showStop
? 'justify-center px-3 py-1.5 rounded-lg bg-gradient-to-br from-red-600 to-red-700 border border-red-500/60 shadow-red-500/30 ring-red-500/20 hover:ring-red-500/40 text-white'
: autoPlay
Expand Down
21 changes: 12 additions & 9 deletions frontend/src/components/message/MessagePart.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import type { components } from '@/api/opencode-types'
import { Volume2, VolumeX, Loader2 } from 'lucide-react'
Expand Down Expand Up @@ -68,6 +69,7 @@ interface TTSButtonProps {
}

function TTSButton({ messageId, content, className = "" }: TTSButtonProps) {
const { t } = useTranslation()
const { speakMessage, stop, isEnabled, isPlaying, isLoading, activeMessageId } = useTTS()

if (!isEnabled || !content.trim()) {
Expand All @@ -88,7 +90,7 @@ function TTSButton({ messageId, content, className = "" }: TTSButtonProps) {
<button
onClick={handleClick}
className={`p-1.5 rounded ${isThisPlaying ? 'bg-red-500/20 text-red-500 hover:bg-red-500/30' : 'bg-card hover:bg-card-hover text-muted-foreground hover:text-foreground'} ${className}`}
title={isThisPlaying ? "Stop playback" : "Read aloud"}
title={isThisPlaying ? t('message.stopPlayback') : t('message.readAloud')}
disabled={isLoading && !isThisPlaying}
>
{isLoading && isThisPlaying ? (
Expand All @@ -103,6 +105,7 @@ function TTSButton({ messageId, content, className = "" }: TTSButtonProps) {
}

export const MessagePart = memo(function MessagePart({ part, role, allParts, partIndex, onFileClick, onChildSessionClick, messageTextContent }: MessagePartProps) {
const { t } = useTranslation()
const { preferences } = useSettings()
const simpleChatMode = preferences?.simpleChatMode ?? false
const showReasoning = preferences?.showReasoning ?? false
Expand Down Expand Up @@ -130,7 +133,7 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par
return (
<details className="border border-border rounded-lg my-2">
<summary className="px-4 py-2 bg-muted hover:bg-accent cursor-pointer text-sm font-medium">
Reasoning
{t('message.reasoning')}
</summary>
<div className="p-4 bg-card text-sm text-muted-foreground whitespace-pre-wrap">
{part.text}
Expand All @@ -141,26 +144,26 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par
if (simpleChatMode) return null
return (
<div className="border border-border rounded-lg p-4 my-2 bg-card">
<div className="text-xs text-muted-foreground font-mono">Snapshot: {part.snapshot}</div>
<div className="text-xs text-muted-foreground font-mono">{t('message.snapshot')}: {part.snapshot}</div>
</div>
)
case 'agent':
if (simpleChatMode) return null
return (
<div className="border border-border rounded-lg p-4 my-2 bg-card">
<div className="text-sm font-medium text-blue-600 dark:text-blue-400">Agent: {part.name}</div>
<div className="text-sm font-medium text-blue-600 dark:text-blue-400">{t('message.agentLabel')}: {part.name}</div>
</div>
)
case 'step-finish':
if (simpleChatMode) return null
{
const isFree = part.cost === 0
const totalTokens = part.tokens.input + part.tokens.output + (part.tokens.cache?.read || 0)
const costText = isMobile && isFree ? null : <span>${part.cost.toFixed(4)} • {totalTokens} tokens</span>
const costText = isMobile && isFree ? null : <span>${part.cost.toFixed(4)} • {totalTokens} {t('message.tokens')}</span>
return (
<div className="text-xs text-muted-foreground my-1 flex items-center gap-2">
{costText}
<CopyButton content={copyableContent} title="Copy step complete" />
<CopyButton content={copyableContent} title={t('code.copyStepComplete')} />
{messageTextContent && part.messageID && <TTSButton messageId={part.messageID} content={messageTextContent} />}
</div>
)
Expand All @@ -169,18 +172,18 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-muted border border-border text-sm text-foreground">
<span className="text-blue-600 dark:text-blue-400">@</span>
<span className="font-medium">{part.filename || 'File'}</span>
<span className="font-medium">{part.filename || t('message.file')}</span>
</span>
)
case 'retry':
return <RetryPart part={part as RetryPartType} />
case 'subtask': {
const label = part.description || part.prompt || 'Sub-agent task'
const label = part.description || part.prompt || t('message.subAgentTask')
return (
<div className="my-1 w-full rounded-lg border border-blue-500/20 bg-blue-500/5 px-3 py-1.5 text-left text-xs text-muted-foreground shadow-sm shadow-blue-500/5">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{label}</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">sub-agent</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">{t('message.subAgent')}</span>
</div>
</div>
)
Expand Down
Loading