Merge remote-tracking branch 'origin/main' into bb/remove-composer-message-shadows
# Conflicts: # apps/desktop/src/components/assistant-ui/tool-fallback.tsx
This commit is contained in:
@@ -2,11 +2,12 @@ import { useRef } from 'react'
|
||||
|
||||
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
|
||||
files: { icon: 'cloud-upload', label: 'Drop files to attach' },
|
||||
session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
|
||||
const ICONS: Record<'files' | 'session', string> = {
|
||||
files: 'cloud-upload',
|
||||
session: 'comment-discussion'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,13 +18,16 @@ const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
|
||||
* fade-out so the label doesn't blank.
|
||||
*/
|
||||
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
||||
const { t } = useI18n()
|
||||
const lastKind = useRef<'files' | 'session'>('files')
|
||||
|
||||
if (kind) {
|
||||
lastKind.current = kind
|
||||
}
|
||||
|
||||
const { icon, label } = COPY[kind ?? lastKind.current]
|
||||
const resolvedKind = kind ?? lastKind.current
|
||||
const icon = ICONS[resolvedKind]
|
||||
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
|
||||
@@ -9,6 +10,7 @@ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '
|
||||
// backend (lazily spawned). Keeps the last profile name through the fade-out so
|
||||
// the label doesn't blank. Purely visual — pointer-events-none.
|
||||
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
||||
const { t } = useI18n()
|
||||
const [frame, setFrame] = useState(0)
|
||||
const [label, setLabel] = useState<null | string>(profile)
|
||||
|
||||
@@ -38,7 +40,7 @@ export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
|
||||
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
|
||||
Waking up {label}…
|
||||
{t.composer.wakingProfile(label ?? '')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,39 +17,49 @@ export interface MicRecording {
|
||||
heardSpeech: boolean
|
||||
}
|
||||
|
||||
export interface MicRecorderErrorCopy {
|
||||
microphoneAccessDenied: string
|
||||
microphoneConstraintsUnsupported: string
|
||||
microphoneInUse: string
|
||||
microphonePermissionDenied: string
|
||||
microphoneStartFailed: string
|
||||
microphoneUnsupported: string
|
||||
noMicrophone: string
|
||||
}
|
||||
|
||||
interface MicRecorderHandle {
|
||||
start: (options?: MicRecorderOptions) => Promise<void>
|
||||
stop: () => Promise<MicRecording | null>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
function micError(error: unknown): Error {
|
||||
function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
const name = error instanceof DOMException ? error.name : ''
|
||||
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
||||
return new Error('Microphone permission was denied.')
|
||||
return new Error(copy.microphonePermissionDenied)
|
||||
}
|
||||
|
||||
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
|
||||
return new Error('No microphone was found.')
|
||||
return new Error(copy.noMicrophone)
|
||||
}
|
||||
|
||||
if (name === 'NotReadableError' || name === 'TrackStartError') {
|
||||
return new Error('Microphone is already in use by another app.')
|
||||
return new Error(copy.microphoneInUse)
|
||||
}
|
||||
|
||||
if (name === 'OverconstrainedError') {
|
||||
return new Error('Microphone constraints are not supported by this device.')
|
||||
return new Error(copy.microphoneConstraintsUnsupported)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error
|
||||
}
|
||||
|
||||
return new Error('Could not start microphone recording.')
|
||||
return new Error(copy.microphoneStartFailed)
|
||||
}
|
||||
|
||||
export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } {
|
||||
export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } {
|
||||
const [level, setLevel] = useState(0)
|
||||
const [recording, setRecording] = useState(false)
|
||||
|
||||
@@ -158,13 +168,13 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
throw new Error('This runtime does not support microphone recording.')
|
||||
throw new Error(copy.microphoneUnsupported)
|
||||
}
|
||||
|
||||
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
|
||||
|
||||
if (permitted === false) {
|
||||
throw new Error('Microphone access denied.')
|
||||
throw new Error(copy.microphoneAccessDenied)
|
||||
}
|
||||
|
||||
let stream: MediaStream
|
||||
@@ -174,7 +184,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
|
||||
audio: { echoCancellation: true, noiseSuppression: true }
|
||||
})
|
||||
} catch (error) {
|
||||
throw micError(error)
|
||||
throw micError(error, copy)
|
||||
}
|
||||
|
||||
const mimeType =
|
||||
@@ -188,7 +198,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
|
||||
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
||||
} catch (error) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
throw micError(error)
|
||||
throw micError(error, copy)
|
||||
}
|
||||
|
||||
chunksRef.current = []
|
||||
@@ -231,7 +241,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
|
||||
}
|
||||
|
||||
recorder.onerror = event => {
|
||||
const error = micError((event as Event & { error?: unknown }).error)
|
||||
const error = micError((event as Event & { error?: unknown }).error, copy)
|
||||
const resolver = stopResolverRef.current
|
||||
stopResolverRef.current = null
|
||||
cleanup()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -32,7 +33,9 @@ export function useVoiceConversation({
|
||||
pendingResponse,
|
||||
consumePendingResponse
|
||||
}: VoiceConversationOptions) {
|
||||
const { handle, level } = useMicRecorder()
|
||||
const { t } = useI18n()
|
||||
const voiceCopy = t.notifications.voice
|
||||
const { handle, level } = useMicRecorder(voiceCopy)
|
||||
const [status, setStatus] = useState<ConversationStatus>('idle')
|
||||
const [muted, setMuted] = useState(false)
|
||||
const turnTimeoutRef = useRef<number | null>(null)
|
||||
@@ -168,7 +171,7 @@ export function useVoiceConversation({
|
||||
await onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice transcription failed')
|
||||
notifyError(error, voiceCopy.transcriptionFailed)
|
||||
|
||||
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
|
||||
pendingStartRef.current = true
|
||||
@@ -180,7 +183,7 @@ export function useVoiceConversation({
|
||||
turnClosingRef.current = false
|
||||
}
|
||||
},
|
||||
[handle, onSubmit, onTranscribeAudio]
|
||||
[handle, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
|
||||
)
|
||||
|
||||
const startListening = useCallback(async () => {
|
||||
@@ -201,7 +204,7 @@ export function useVoiceConversation({
|
||||
silenceMs: 1_250,
|
||||
idleSilenceMs: 12_000,
|
||||
onError: error => {
|
||||
notifyError(error, 'Microphone failed')
|
||||
notifyError(error, voiceCopy.microphoneFailed)
|
||||
pendingStartRef.current = false
|
||||
onFatalError?.()
|
||||
},
|
||||
@@ -210,12 +213,12 @@ export function useVoiceConversation({
|
||||
setStatus('listening')
|
||||
turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not start voice session')
|
||||
notifyError(error, voiceCopy.couldNotStartSession)
|
||||
pendingStartRef.current = false
|
||||
setStatus('idle')
|
||||
onFatalError?.()
|
||||
}
|
||||
}, [handle, handleTurn, onFatalError])
|
||||
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
|
||||
|
||||
const speak = useCallback(async (text: string) => {
|
||||
setStatus('speaking')
|
||||
@@ -223,7 +226,7 @@ export function useVoiceConversation({
|
||||
try {
|
||||
await playSpeechText(text, { source: 'voice-conversation' })
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice playback failed')
|
||||
notifyError(error, voiceCopy.playbackFailed)
|
||||
} finally {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
@@ -232,14 +235,14 @@ export function useVoiceConversation({
|
||||
setStatus('idle')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [voiceCopy.playbackFailed])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!onTranscribeAudio) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Voice unavailable',
|
||||
message: 'Configure speech-to-text to use voice mode.'
|
||||
title: voiceCopy.unavailable,
|
||||
message: voiceCopy.configureSpeechToText
|
||||
})
|
||||
onFatalError?.()
|
||||
|
||||
@@ -252,7 +255,7 @@ export function useVoiceConversation({
|
||||
consumePendingResponse()
|
||||
pendingStartRef.current = true
|
||||
await startListening()
|
||||
}, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening])
|
||||
}, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable])
|
||||
|
||||
const end = useCallback(async () => {
|
||||
pendingStartRef.current = false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { VoiceActivityState, VoiceStatus } from '../types'
|
||||
@@ -19,7 +20,9 @@ export function useVoiceRecorder({
|
||||
focusInput,
|
||||
onTranscript
|
||||
}: VoiceRecorderOptions) {
|
||||
const { handle, level, recording } = useMicRecorder()
|
||||
const { t } = useI18n()
|
||||
const voiceCopy = t.notifications.voice
|
||||
const { handle, level, recording } = useMicRecorder(voiceCopy)
|
||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>('idle')
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||
const startedAtRef = useRef(0)
|
||||
@@ -62,12 +65,12 @@ export function useVoiceRecorder({
|
||||
const transcript = (await onTranscribeAudio(result.audio)).trim()
|
||||
|
||||
if (!transcript) {
|
||||
notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' })
|
||||
notify({ kind: 'warning', title: voiceCopy.noSpeechDetected, message: voiceCopy.tryRecordingAgain })
|
||||
} else {
|
||||
onTranscript(transcript)
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice transcription failed')
|
||||
notifyError(error, voiceCopy.transcriptionFailed)
|
||||
} finally {
|
||||
setVoiceStatus('idle')
|
||||
focusInput()
|
||||
@@ -76,13 +79,13 @@ export function useVoiceRecorder({
|
||||
|
||||
const start = async () => {
|
||||
if (!onTranscribeAudio) {
|
||||
notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' })
|
||||
notify({ kind: 'warning', title: voiceCopy.unavailable, message: voiceCopy.transcriptionUnavailable })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await handle.start({ onError: error => notifyError(error, 'Voice recording failed') })
|
||||
await handle.start({ onError: error => notifyError(error, voiceCopy.recordingFailed) })
|
||||
startedAtRef.current = Date.now()
|
||||
setElapsedSeconds(0)
|
||||
setVoiceStatus('recording')
|
||||
@@ -91,7 +94,7 @@ export function useVoiceRecorder({
|
||||
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
|
||||
} catch (error) {
|
||||
setVoiceStatus('idle')
|
||||
notifyError(error, 'Voice recording failed')
|
||||
notifyError(error, voiceCopy.recordingFailed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1531,7 +1531,7 @@ export function ChatBar({
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
||||
Editing queued turn in composer
|
||||
{t.composer.editingQueuedInComposer}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
@@ -1540,14 +1540,14 @@ export function ChatBar({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('save')}
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
|
||||
function renderPopover(kind: '@' | '/', loading = false) {
|
||||
const onHover = vi.fn()
|
||||
const onPick = vi.fn()
|
||||
|
||||
const rendered = render(
|
||||
<I18nProvider configClient={null} initialLocale="zh">
|
||||
<ComposerTriggerPopover activeIndex={0} items={[]} kind={kind} loading={loading} onHover={onHover} onPick={onPick} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
return { ...rendered, onHover, onPick }
|
||||
}
|
||||
|
||||
describe('ComposerTriggerPopover i18n', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders localized empty lookup copy for @ references', () => {
|
||||
const { container } = renderPopover('@')
|
||||
|
||||
expect(screen.getByText('没有匹配项。')).toBeTruthy()
|
||||
expect(container.textContent).toContain('试试')
|
||||
expect(container.textContent).toContain('@file:')
|
||||
expect(container.textContent).toContain('或')
|
||||
expect(container.textContent).toContain('@folder:')
|
||||
})
|
||||
|
||||
it('renders localized loading copy for slash commands', () => {
|
||||
const { container } = renderPopover('/', true)
|
||||
|
||||
expect(screen.getByText('查找中…')).toBeTruthy()
|
||||
expect(container.textContent).toContain('/help')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
@@ -60,6 +61,9 @@ export function ComposerTriggerPopover({
|
||||
onPick,
|
||||
placement = 'top'
|
||||
}: ComposerTriggerPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.composer
|
||||
|
||||
return (
|
||||
<div
|
||||
className={placement === 'bottom' ? COMPLETION_DRAWER_BELOW_CLASS : COMPLETION_DRAWER_CLASS}
|
||||
@@ -69,15 +73,15 @@ export function ComposerTriggerPopover({
|
||||
role="listbox"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up…' : 'No matches.'}>
|
||||
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
Try <span className="font-mono text-foreground/80">@file:</span> or{' '}
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Try <span className="font-mono text-foreground/80">/help</span>.
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
@@ -193,9 +194,11 @@ const attachToMain = (attachment: ComposerAttachment) => {
|
||||
}
|
||||
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const addTextToDraft = useCallback((text: string) => {
|
||||
requestComposerInsert(text, { mode: 'block' })
|
||||
}, [])
|
||||
}, [copy.imagePreviewFailed])
|
||||
|
||||
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
|
||||
const trimmed = text.trim()
|
||||
@@ -300,7 +303,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image preview failed')
|
||||
notifyError(err, copy.imagePreviewFailed)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -322,28 +325,28 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob))
|
||||
|
||||
if (!savedPath) {
|
||||
notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' })
|
||||
notify({ kind: 'error', title: copy.imageAttach, message: copy.imageWriteFailed })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return attachImagePath(savedPath)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image attach failed')
|
||||
notifyError(err, copy.imageAttachFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[attachImagePath]
|
||||
[attachImagePath, copy.imageAttach, copy.imageAttachFailed, copy.imageWriteFailed]
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Attach images',
|
||||
title: copy.attachImages,
|
||||
defaultPath: currentCwd || undefined,
|
||||
filters: [
|
||||
{
|
||||
name: 'Images',
|
||||
name: t.composer.images,
|
||||
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
|
||||
}
|
||||
]
|
||||
@@ -356,7 +359,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
for (const path of paths) {
|
||||
await attachImagePath(path)
|
||||
}
|
||||
}, [attachImagePath, currentCwd])
|
||||
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
|
||||
|
||||
const pasteClipboardImage = useCallback(async () => {
|
||||
try {
|
||||
@@ -365,8 +368,8 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
if (!path) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Clipboard',
|
||||
message: 'No image found in clipboard'
|
||||
title: copy.clipboard,
|
||||
message: copy.noClipboardImage
|
||||
})
|
||||
|
||||
return
|
||||
@@ -374,9 +377,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
await attachImagePath(path)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Clipboard paste failed')
|
||||
notifyError(err, copy.clipboardPasteFailed)
|
||||
}
|
||||
}, [attachImagePath])
|
||||
}, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
|
||||
|
||||
const attachContextFolderPath = useCallback(
|
||||
(folderPath: string) => {
|
||||
@@ -477,12 +480,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
}
|
||||
|
||||
if (!attached && lastFailure) {
|
||||
notify({ kind: 'warning', title: 'Drop files', message: lastFailure })
|
||||
notify({ kind: 'warning', title: copy.dropFiles, message: lastFailure })
|
||||
}
|
||||
|
||||
return attached
|
||||
},
|
||||
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath]
|
||||
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles]
|
||||
)
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify } from '@/store/notifications'
|
||||
@@ -74,6 +75,9 @@ interface ConsoleRowProps {
|
||||
}
|
||||
|
||||
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.preview.console
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -81,7 +85,7 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
selected && 'border-border/60 bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<Tip label={selected ? 'Deselect entry' : 'Select entry'}>
|
||||
<Tip label={selected ? copy.deselect : copy.select}>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
@@ -108,13 +112,13 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
errorMessage="Could not copy console output"
|
||||
errorMessage={copy.copyFailed}
|
||||
iconClassName="size-3"
|
||||
label="Copy this entry"
|
||||
label={copy.copyEntry}
|
||||
showLabel={false}
|
||||
text={copyText}
|
||||
/>
|
||||
<Tip label="Send this entry to chat">
|
||||
<Tip label={copy.sendEntry}>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
@@ -129,12 +133,13 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
}
|
||||
|
||||
export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
|
||||
const { t } = useI18n()
|
||||
const logCount = useStore(consoleState.$logCount)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelBottom />
|
||||
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
|
||||
{logCount > 0 && <span className="sr-only">{t.preview.console.messages(logCount)}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -152,6 +157,8 @@ export function PreviewConsolePanel({
|
||||
consoleState,
|
||||
startConsoleResize
|
||||
}: PreviewConsolePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.preview.console
|
||||
const consoleHeight = useStore(consoleState.$height)
|
||||
const logs = useStore(consoleState.$logs)
|
||||
const selectedLogIds = useStore(consoleState.$selectedLogIds)
|
||||
@@ -188,14 +195,14 @@ export function PreviewConsolePanel({
|
||||
return
|
||||
}
|
||||
|
||||
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
|
||||
const block = [copy.promptHeader, '```', ...entries.map(formatLogLine), '```'].join('\n')
|
||||
|
||||
requestComposerInsert(block, { mode: 'block', target: 'main' })
|
||||
consoleState.clearSelection()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Sent to chat',
|
||||
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
|
||||
title: copy.sentTitle,
|
||||
message: copy.sentMessage(entries.length)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,7 +212,7 @@ export function PreviewConsolePanel({
|
||||
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
aria-label="Resize preview console"
|
||||
aria-label={copy.resize}
|
||||
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
|
||||
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
|
||||
onPointerDown={startConsoleResize}
|
||||
@@ -216,10 +223,10 @@ export function PreviewConsolePanel({
|
||||
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
|
||||
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
||||
<PanelBottom className="size-3.5" />
|
||||
Preview Console
|
||||
{copy.title}
|
||||
{selectedLogIds.size > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{selectedLogIds.size} selected
|
||||
{copy.selected(selectedLogIds.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -231,18 +238,18 @@ export function PreviewConsolePanel({
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
Send to chat
|
||||
{copy.sendToChat}
|
||||
</button>
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
errorMessage="Could not copy console output"
|
||||
errorMessage={copy.copyFailed}
|
||||
iconClassName="size-3"
|
||||
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
|
||||
label={visibleSelection.length > 0 ? copy.copySelected : copy.copyAll}
|
||||
text={() => formatConsoleEntries(sendableLogs)}
|
||||
>
|
||||
Copy
|
||||
{copy.copy}
|
||||
</CopyButton>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
@@ -251,7 +258,7 @@ export function PreviewConsolePanel({
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Clear
|
||||
{copy.clear}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +282,7 @@ export function PreviewConsolePanel({
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
|
||||
<div className="py-2 text-muted-foreground/70">{copy.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -143,7 +144,7 @@ function filePathForTarget(target: PreviewTarget) {
|
||||
|
||||
function formatBytes(bytes: number | undefined) {
|
||||
if (!bytes) {
|
||||
return 'unknown size'
|
||||
return translateNow('preview.unknownSize')
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
@@ -296,6 +297,8 @@ function MarkdownPreview({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
@@ -303,7 +306,7 @@ function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: ()
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{asSource ? 'PREVIEW' : 'SOURCE'}
|
||||
{asSource ? t.preview.renderedPreview : t.preview.source}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -330,6 +333,7 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
|
||||
}
|
||||
|
||||
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
||||
const { t } = useI18n()
|
||||
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
||||
const [selection, setSelection] = useState<LineSelection | null>(null)
|
||||
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
||||
@@ -373,7 +377,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title="Click to select · shift-click to extend · drag to composer"
|
||||
title={t.preview.sourceLineTitle}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
@@ -408,6 +412,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
}
|
||||
|
||||
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||
const { t } = useI18n()
|
||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||
const [forcePreview, setForcePreview] = useState(false)
|
||||
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
|
||||
@@ -482,11 +487,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
||||
|
||||
if (state.loading) {
|
||||
return <PageLoader label="Loading preview" />
|
||||
return <PageLoader label={t.preview.loading} />
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
|
||||
return <PreviewEmptyState body={state.error} title={t.preview.unavailable} />
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -501,11 +506,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
<PreviewEmptyState
|
||||
body={
|
||||
binary
|
||||
? `Previewing ${target.label} may show unreadable text.`
|
||||
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
|
||||
? t.preview.binaryBody(target.label)
|
||||
: t.preview.largeBody(target.label, formatBytes(size))
|
||||
}
|
||||
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
|
||||
title={binary ? 'This looks like a binary file' : 'This file is large'}
|
||||
primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }}
|
||||
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
|
||||
tone="warning"
|
||||
/>
|
||||
)
|
||||
@@ -532,7 +537,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
<div className="h-full overflow-auto bg-transparent">
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
Showing first 512 KB.
|
||||
{t.preview.truncated}
|
||||
</div>
|
||||
)}
|
||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||
@@ -547,8 +552,8 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
|
||||
title="No inline preview"
|
||||
body={t.preview.noInlineBody(target.mimeType || '')}
|
||||
title={t.preview.noInlineTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -46,18 +47,18 @@ interface PreviewLoadErrorState {
|
||||
const FILE_RELOAD_DEBOUNCE_MS = 200
|
||||
const SERVER_RESTART_TIMEOUT_MS = 45_000
|
||||
|
||||
function loadErrorTitle(error: PreviewLoadErrorState): string {
|
||||
function loadErrorTitle(error: PreviewLoadErrorState, copy: Translations['preview']['web']): string {
|
||||
const description = error.description.toLowerCase()
|
||||
|
||||
if (description.includes('module script') || description.includes('mime type')) {
|
||||
return 'Preview app failed to boot'
|
||||
return copy.appFailedToBoot
|
||||
}
|
||||
|
||||
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
|
||||
return 'Server not found'
|
||||
return copy.serverNotFound
|
||||
}
|
||||
|
||||
return 'Preview failed to load'
|
||||
return copy.failedToLoad
|
||||
}
|
||||
|
||||
function isModuleMimeError(message: string): boolean {
|
||||
@@ -79,6 +80,9 @@ function PreviewLoadError({
|
||||
onRetry: () => void
|
||||
restarting?: boolean
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.preview.web
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={
|
||||
@@ -98,17 +102,17 @@ function PreviewLoadError({
|
||||
</>
|
||||
}
|
||||
consoleHeight={consoleHeight}
|
||||
primaryAction={{ label: 'Try again', onClick: onRetry }}
|
||||
primaryAction={{ label: copy.tryAgain, onClick: onRetry }}
|
||||
secondaryAction={
|
||||
onRestartServer
|
||||
? {
|
||||
disabled: restarting,
|
||||
label: restarting ? 'Hermes is restarting...' : 'Ask Hermes to restart the server',
|
||||
label: restarting ? copy.restarting : copy.askRestart,
|
||||
onClick: onRestartServer
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={loadErrorTitle(error)}
|
||||
title={loadErrorTitle(error, copy)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -122,6 +126,8 @@ export function PreviewPane({
|
||||
setTitlebarToolGroup,
|
||||
target
|
||||
}: PreviewPaneProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.preview.web
|
||||
const [consoleState] = useState(() => createPreviewConsoleState())
|
||||
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const consoleShouldStickRef = useRef(true)
|
||||
@@ -239,23 +245,23 @@ export function PreviewPane({
|
||||
|
||||
appendConsoleEntry({
|
||||
level: 1,
|
||||
message: `Hermes is looking for a preview server to restart (${taskId})`
|
||||
message: copy.lookingRestart(taskId)
|
||||
})
|
||||
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Restarting preview server',
|
||||
message: 'Hermes is working in the background. Watch the preview console for progress.',
|
||||
title: copy.restartingTitle,
|
||||
message: copy.restartingMessage,
|
||||
durationMs: 4000
|
||||
})
|
||||
} catch (error) {
|
||||
appendConsoleEntry({
|
||||
level: 2,
|
||||
message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}`
|
||||
message: copy.startRestartFailed(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
notifyError(error, 'Server restart failed')
|
||||
notifyError(error, copy.restartFailed)
|
||||
}
|
||||
}, [appendConsoleEntry, consoleState, currentUrl, onRestartServer])
|
||||
}, [appendConsoleEntry, consoleState, copy, currentUrl, onRestartServer])
|
||||
|
||||
const toggleDevTools = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
@@ -287,14 +293,14 @@ export function PreviewPane({
|
||||
active: consoleOpen,
|
||||
icon: <PreviewConsoleTitlebarIcon consoleState={consoleState} />,
|
||||
id: `${TITLEBAR_GROUP_ID}-console`,
|
||||
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
|
||||
label: consoleOpen ? copy.hideConsole : copy.showConsole,
|
||||
onSelect: () => consoleState.setOpen(open => !open)
|
||||
},
|
||||
{
|
||||
active: devtoolsOpen,
|
||||
icon: <Bug />,
|
||||
id: `${TITLEBAR_GROUP_ID}-devtools`,
|
||||
label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
|
||||
label: devtoolsOpen ? copy.hideDevTools : copy.openDevTools,
|
||||
onSelect: toggleDevTools
|
||||
}
|
||||
]
|
||||
@@ -304,7 +310,7 @@ export function PreviewPane({
|
||||
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
|
||||
|
||||
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
|
||||
}, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
|
||||
}, [consoleOpen, consoleState, copy, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
|
||||
|
||||
useEffect(() => {
|
||||
if (!consoleOpen) {
|
||||
@@ -343,29 +349,27 @@ export function PreviewPane({
|
||||
previewServerRestart.status === 'running'
|
||||
? previewServerRestart.message
|
||||
: previewServerRestart.status === 'complete'
|
||||
? `Hermes finished restarting the preview server${
|
||||
previewServerRestart.message ? `: ${previewServerRestart.message}` : ''
|
||||
}`
|
||||
: `Server restart failed: ${previewServerRestart.message || 'unknown error'}`
|
||||
? copy.finishedRestarting(previewServerRestart.message)
|
||||
: copy.failedRestarting(previewServerRestart.message || copy.unknownError)
|
||||
})
|
||||
|
||||
if (previewServerRestart.status === 'complete') {
|
||||
reloadPreview()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Preview server restarted',
|
||||
message: previewServerRestart.message?.slice(0, 160) || 'Reloading the preview now.',
|
||||
title: copy.restartedTitle,
|
||||
message: previewServerRestart.message?.slice(0, 160) || copy.reloadingNow,
|
||||
durationMs: 3500
|
||||
})
|
||||
} else if (previewServerRestart.status === 'error') {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Preview restart failed',
|
||||
message: previewServerRestart.message?.slice(0, 200) || 'Hermes could not restart the server.',
|
||||
title: copy.restartFailedTitle,
|
||||
message: previewServerRestart.message?.slice(0, 200) || copy.restartFailedMessage,
|
||||
durationMs: 6000
|
||||
})
|
||||
}
|
||||
}, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url])
|
||||
}, [appendConsoleEntry, copy, currentUrl, previewServerRestart, reloadPreview, target.url])
|
||||
|
||||
useEffect(() => {
|
||||
if (!restartingServer || !previewServerRestart) {
|
||||
@@ -375,14 +379,11 @@ export function PreviewPane({
|
||||
const taskId = previewServerRestart.taskId
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
failPreviewServerRestart(
|
||||
taskId,
|
||||
'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.'
|
||||
)
|
||||
failPreviewServerRestart(taskId, copy.stillWorking)
|
||||
}, SERVER_RESTART_TIMEOUT_MS)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [previewServerRestart, restartingServer])
|
||||
}, [copy.stillWorking, previewServerRestart, restartingServer])
|
||||
|
||||
useEffect(() => {
|
||||
if (reloadRequest === lastReloadRequestRef.current) {
|
||||
@@ -397,10 +398,10 @@ export function PreviewPane({
|
||||
|
||||
appendConsoleEntry({
|
||||
level: 1,
|
||||
message: 'Workspace changed, reloading preview'
|
||||
message: copy.workspaceReloading
|
||||
})
|
||||
reloadPreview()
|
||||
}, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind])
|
||||
}, [appendConsoleEntry, copy.workspaceReloading, reloadPreview, reloadRequest, target.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -432,8 +433,8 @@ export function PreviewPane({
|
||||
level: 1,
|
||||
message:
|
||||
changedCount === 1
|
||||
? `File changed, reloading preview: ${compactUrl(changedUrl)}`
|
||||
: `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}`
|
||||
? copy.fileChanged(compactUrl(changedUrl))
|
||||
: copy.filesChanged(changedCount, compactUrl(changedUrl))
|
||||
})
|
||||
|
||||
reloadPreview()
|
||||
@@ -471,7 +472,7 @@ export function PreviewPane({
|
||||
.catch(error => {
|
||||
appendConsoleEntry({
|
||||
level: 2,
|
||||
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
|
||||
message: copy.watchFailed(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -487,7 +488,7 @@ export function PreviewPane({
|
||||
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
|
||||
}
|
||||
}
|
||||
}, [appendConsoleEntry, reloadPreview, target.kind, target.url])
|
||||
}, [appendConsoleEntry, copy, reloadPreview, target.kind, target.url])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
@@ -535,8 +536,7 @@ export function PreviewPane({
|
||||
|
||||
if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) {
|
||||
setLoadError({
|
||||
description:
|
||||
'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.',
|
||||
description: copy.moduleMimeDescription,
|
||||
url: webview.getURL?.() || target.url
|
||||
})
|
||||
setLoading(false)
|
||||
@@ -567,13 +567,11 @@ export function PreviewPane({
|
||||
|
||||
appendConsoleEntry({
|
||||
level: 3,
|
||||
message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
|
||||
detail.errorDescription || detail.validatedURL || 'unknown error'
|
||||
}`
|
||||
message: copy.loadFailedConsole(errorCode, detail.errorDescription || detail.validatedURL || copy.unknownError)
|
||||
})
|
||||
setLoadError({
|
||||
code: errorCode,
|
||||
description: detail.errorDescription || 'The preview page could not be reached.',
|
||||
description: detail.errorDescription || copy.unreachableDescription,
|
||||
url: detail.validatedURL || webview.getURL?.() || target.url
|
||||
})
|
||||
setLoading(false)
|
||||
@@ -600,7 +598,7 @@ export function PreviewPane({
|
||||
webview.removeEventListener('did-stop-loading', onStop)
|
||||
webview.remove()
|
||||
}
|
||||
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
|
||||
}, [appendConsoleEntry, consoleState, copy, isWebPreview, target.url])
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground">
|
||||
@@ -608,14 +606,14 @@ export function PreviewPane({
|
||||
{!embedded && (
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Tip label={`Open ${currentUrl}`}>
|
||||
<Tip label={copy.openTarget(currentUrl)}>
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
{previewLabel || copy.fallbackTitle}
|
||||
</a>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo } from 'react'
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$rightRailActiveTabId,
|
||||
@@ -48,10 +49,11 @@ function tabLabelFor(target: PreviewTarget): string {
|
||||
const value = target.label || target.path || target.source || target.url
|
||||
const tail = value.split(/[\\/]/).filter(Boolean).at(-1)
|
||||
|
||||
return tail || value || 'Preview'
|
||||
return tail || value || translateNow('preview.tab')
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
|
||||
const { t } = useI18n()
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const activeTabId = useStore($rightRailActiveTabId)
|
||||
const filePreviewTabs = useStore($filePreviewTabs)
|
||||
@@ -59,10 +61,10 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
|
||||
const tabs = useMemo<readonly RailTab[]>(
|
||||
() => [
|
||||
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: 'Preview', target: previewTarget } as RailTab] : []),
|
||||
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
|
||||
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
|
||||
],
|
||||
[filePreviewTabs, previewTarget]
|
||||
[filePreviewTabs, previewTarget, t.preview.tab]
|
||||
)
|
||||
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
|
||||
@@ -134,7 +136,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={`Close ${tab.label}`}
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
@@ -146,7 +148,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close preview pane"
|
||||
aria-label={t.preview.closePane}
|
||||
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
|
||||
onClick={closeRightRail}
|
||||
type="button"
|
||||
|
||||
@@ -92,18 +92,18 @@ const NEW_SESSION_KBD: readonly string[] =
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
label: '',
|
||||
icon: props => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Skills & Tools',
|
||||
label: '',
|
||||
icon: props => <Codicon name="symbol-misc" {...props} />,
|
||||
route: SKILLS_ROUTE
|
||||
},
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
{ id: 'messaging', label: '', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: '', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
]
|
||||
|
||||
const WORKSPACE_PAGE = 5
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -84,6 +85,8 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
|
||||
// profile users see only the "+" (create their first profile); everything else
|
||||
// appears once a second profile exists.
|
||||
export function ProfileRail() {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const profiles = useStore($profiles)
|
||||
const scope = useStore($profileScope)
|
||||
const gatewayProfile = useStore($activeGatewayProfile)
|
||||
@@ -187,11 +190,11 @@ export function ProfileRail() {
|
||||
<ProfilePill
|
||||
active={isAll || onDefault}
|
||||
glyph={isAll ? 'layers' : 'home'}
|
||||
label={onDefault ? 'Show all profiles' : `Switch to ${defaultProfile.name}`}
|
||||
label={onDefault ? p.showAllProfiles : p.switchToProfile(defaultProfile.name)}
|
||||
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
|
||||
/>
|
||||
) : (
|
||||
<ProfilePill active={isAll} glyph="layers" label="All profiles" onSelect={() => setShowAllProfiles(true)} />
|
||||
<ProfilePill active={isAll} glyph="layers" label={p.allProfiles} onSelect={() => setShowAllProfiles(true)} />
|
||||
))}
|
||||
|
||||
{/* Single-profile: the active default's home icon next to the create +. */}
|
||||
@@ -233,9 +236,9 @@ export function ProfileRail() {
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<Tip label="New profile">
|
||||
<Tip label={p.newProfile}>
|
||||
<button
|
||||
aria-label="New profile"
|
||||
aria-label={p.newProfile}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
type="button"
|
||||
@@ -246,7 +249,7 @@ export function ProfileRail() {
|
||||
</div>
|
||||
|
||||
{multiProfile && (
|
||||
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
)}
|
||||
|
||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||
@@ -328,6 +331,8 @@ const LONG_PRESS_MS = 450
|
||||
// context-menu triggers via nested asChild Slots, so a single element keeps the
|
||||
// dnd listeners, hover tip, and right-click menu.
|
||||
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pressTimer = useRef<null | number>(null)
|
||||
@@ -436,27 +441,27 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
|
||||
statusbar) — Radix then flips the menu up instead of squishing it. */}
|
||||
<ContextMenuContent
|
||||
aria-label={`Actions for ${label}`}
|
||||
aria-label={p.actionsFor(label)}
|
||||
className="w-40"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
>
|
||||
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
<span>Color…</span>
|
||||
<span>{p.color}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onRename}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Rename</span>
|
||||
<span>{p.rename}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
<span>{t.common.delete}</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<PopoverContent
|
||||
aria-label={`Color for ${label}`}
|
||||
aria-label={p.colorFor(label)}
|
||||
className="w-auto p-2"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
side="top"
|
||||
@@ -464,7 +469,7 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{PROFILE_SWATCHES.map(swatch => (
|
||||
<button
|
||||
aria-label={`Set color ${swatch}`}
|
||||
aria-label={p.setColor(swatch)}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
key={swatch}
|
||||
onClick={() => pickColor(swatch)}
|
||||
@@ -483,7 +488,7 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="sync" size="0.75rem" />
|
||||
Auto
|
||||
{p.autoColor}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
Reference in New Issue
Block a user