feat(desktop): add i18n with Simplified Chinese (zh-Hans) support
Introduce a lightweight React context-based i18n layer for the desktop app and translate the UI into Simplified Chinese. - New apps/desktop/src/i18n module: typed Translations interface, en + zh locale tables, I18nProvider/useI18n, localStorage-persisted locale (defaults to English), and language endonym metadata for the picker. - Wire I18nProvider at the app root in main.tsx. - Refactor 24 desktop screens/components to read strings from the `t` object instead of hard-coded English. - Add a unit test for the i18n context.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
@@ -26,6 +27,8 @@ export function AttachmentList({
|
||||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
||||
@@ -53,12 +56,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${attachment.label}`)
|
||||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
@@ -97,7 +100,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
aria-label={c.removeAttachment(attachment.label)}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
|
||||
@@ -11,29 +11,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
|
||||
{
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
label: 'Code review',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
{
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
label: 'Implementation plan',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
{
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
label: 'Explain this',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
]
|
||||
const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
@@ -44,6 +29,8 @@ export function ContextMenu({
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
// Prompt snippets used to be a Radix submenu. That submenu didn't open
|
||||
// reliably when the parent menu was positioned at the bottom of the
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
@@ -71,78 +58,81 @@ export function ContextMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
{c.attachLabel}
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
{c.files}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
{c.folder}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
{c.images}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
{c.pasteImage}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
{c.url}
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
Prompt snippets…
|
||||
{c.promptSnippets}
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
|
||||
files inline.
|
||||
{c.tipPre}
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
{c.tipPost}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prompt snippets</DialogTitle>
|
||||
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
|
||||
<DialogTitle>{c.snippetsTitle}</DialogTitle>
|
||||
<DialogDescription>{c.snippetsDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
{SNIPPET_KEYS.map(key => {
|
||||
const snippet = c.snippets[key]
|
||||
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -175,15 +165,8 @@ interface ContextMenuProps {
|
||||
state: ChatBarState
|
||||
}
|
||||
|
||||
interface PromptSnippet {
|
||||
description: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PromptSnippetsDialogProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
snippets: readonly PromptSnippet[]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -55,6 +56,9 @@ export function ComposerControls({
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
@@ -65,9 +69,9 @@ export function ComposerControls({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{showVoicePrimary ? (
|
||||
<Tip label="Start voice conversation">
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
aria-label="Start voice conversation"
|
||||
aria-label={c.startVoice}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
@@ -81,9 +85,9 @@ export function ComposerControls({
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
@@ -113,25 +117,27 @@ function ConversationPill({
|
||||
onToggleMute,
|
||||
status
|
||||
}: ConversationProps & { disabled: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const speaking = status === 'speaking'
|
||||
const listening = status === 'listening' && !muted
|
||||
|
||||
const label =
|
||||
status === 'speaking'
|
||||
? 'Speaking'
|
||||
? c.speaking
|
||||
: status === 'transcribing'
|
||||
? 'Transcribing'
|
||||
? c.transcribing
|
||||
: status === 'thinking'
|
||||
? 'Thinking'
|
||||
? c.thinking
|
||||
: muted
|
||||
? 'Muted'
|
||||
: 'Listening'
|
||||
? c.muted
|
||||
: c.listening
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Button
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
@@ -148,32 +154,34 @@ function ConversationPill({
|
||||
</Tip>
|
||||
{listening && (
|
||||
<Button
|
||||
aria-label="Stop listening and send"
|
||||
aria-label={c.stopListening}
|
||||
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('submit')
|
||||
onStopTurn()
|
||||
}}
|
||||
title={c.stopListening}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Square className="fill-current" size={11} />
|
||||
<span>Stop</span>
|
||||
<span>{c.stopShort}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label="End voice conversation"
|
||||
aria-label={c.endConversation}
|
||||
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
onEnd()
|
||||
}}
|
||||
title={c.endConversation}
|
||||
type="button"
|
||||
>
|
||||
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
|
||||
<span>End</span>
|
||||
<span>{c.endShort}</span>
|
||||
</Button>
|
||||
<span className="sr-only" role="status">
|
||||
{label}
|
||||
@@ -220,10 +228,12 @@ function DictationButton({
|
||||
status: VoiceStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const active = state.active || status !== 'idle'
|
||||
|
||||
const aria =
|
||||
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
|
||||
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
|
||||
|
||||
return (
|
||||
<Tip label={aria}>
|
||||
|
||||
@@ -1,44 +1,32 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMANDS: [string, string][] = [
|
||||
['/help', 'full list of commands + hotkeys'],
|
||||
['/clear', 'start a new session'],
|
||||
['/resume', 'resume a prior session'],
|
||||
['/details', 'control transcript detail level'],
|
||||
['/copy', 'copy selection or last assistant message'],
|
||||
['/quit', 'exit hermes']
|
||||
]
|
||||
|
||||
const HOTKEYS: [string, string][] = [
|
||||
['@', 'reference files, folders, urls, git'],
|
||||
['/', 'slash command palette'],
|
||||
['?', 'this quick help (delete to dismiss)'],
|
||||
['Enter', 'send · Shift+Enter for newline'],
|
||||
['Cmd/Ctrl+K', 'send next queued turn'],
|
||||
['Cmd/Ctrl+L', 'redraw'],
|
||||
['Esc', 'close popover · cancel run'],
|
||||
['↑ / ↓', 'cycle popover / history']
|
||||
]
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
return (
|
||||
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
|
||||
<Section title="Common commands">
|
||||
{COMMON_COMMANDS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} mono />
|
||||
<Section title={c.commonCommands}>
|
||||
{COMMON_COMMAND_KEYS.map(key => (
|
||||
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Hotkeys">
|
||||
{HOTKEYS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} />
|
||||
<Section title={c.hotkeys}>
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
|
||||
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
|
||||
<span className="font-mono text-foreground/80">/help</span> {c.helpFooter}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
@@ -84,29 +85,6 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
||||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
// Resting composer placeholders. New sessions get open-ended starters; an
|
||||
// existing chat gets phrasings that read as a continuation of the thread.
|
||||
// One is picked at random per session (stable until the session changes).
|
||||
const NEW_SESSION_PLACEHOLDERS = [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
]
|
||||
|
||||
const FOLLOW_UP_PLACEHOLDERS = [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
]
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
interface QueueEditState {
|
||||
@@ -190,7 +168,10 @@ export function ChatBar({
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -198,7 +179,7 @@ export function ChatBar({
|
||||
// started session (null → id, on the first send) is treated as the same
|
||||
// conversation so the placeholder doesn't visibly flip mid-stream.
|
||||
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
||||
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
|
||||
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
|
||||
)
|
||||
|
||||
const prevSessionIdRef = useRef(sessionId)
|
||||
@@ -217,16 +198,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? 'Reconnecting to Hermes…'
|
||||
: 'Starting Hermes...'
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
: restingPlaceholder
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
@@ -1213,7 +1194,7 @@ export function ChatBar({
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
aria-label={t.composer.message}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
@@ -16,10 +17,12 @@ interface QueuePanelProps {
|
||||
onSendNow: (id: string) => void
|
||||
}
|
||||
|
||||
const entryPreview = (entry: QueuedPromptEntry) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
|
||||
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
|
||||
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -34,7 +37,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
|
||||
<span className="truncate">{entries.length} Queued</span>
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
@@ -57,17 +60,17 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p>
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && (
|
||||
<span>
|
||||
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
|
||||
{c.attachments(attachmentsCount)}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
Editing in composer
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -81,9 +84,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Tip label="Edit queued turn">
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label="Edit queued turn"
|
||||
aria-label={c.editQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
@@ -94,9 +97,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Send queued turn now">
|
||||
<Tip label={c.sendQueuedNow}>
|
||||
<Button
|
||||
aria-label="Send queued turn now"
|
||||
aria-label={c.sendQueuedNow}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
@@ -107,9 +110,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Delete queued turn">
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label="Delete queued turn"
|
||||
aria-label={c.deleteQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
@@ -10,6 +11,8 @@ interface SkinSlashPopoverProps {
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
@@ -21,7 +24,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Desktop theme suggestions"
|
||||
aria-label={c.themeSuggestions}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
@@ -29,8 +32,10 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title="No matching themes.">
|
||||
Try <span className="font-mono text-foreground/80">/skin list</span>.
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Globe } from '@/lib/icons'
|
||||
|
||||
const URL_HINT = /^https?:\/\//i
|
||||
@@ -29,6 +30,8 @@ export function UrlDialog({
|
||||
open: boolean
|
||||
value: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const trimmed = value.trim()
|
||||
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
|
||||
|
||||
@@ -43,8 +46,8 @@ export function UrlDialog({
|
||||
<Globe className="size-4" />
|
||||
</span>
|
||||
<div className="grid gap-0.5 text-left">
|
||||
<DialogTitle>Attach a URL</DialogTitle>
|
||||
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
|
||||
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
|
||||
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -60,23 +63,24 @@ export function UrlDialog({
|
||||
autoCorrect="off"
|
||||
inputMode="url"
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="https://example.com/post"
|
||||
placeholder={c.urlPlaceholder}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
{trimmed.length > 0 && !looksLikeUrl && (
|
||||
<p className="text-xs text-muted-foreground/85">
|
||||
Include the full URL, e.g. <span className="font-mono">https://…</span>
|
||||
{c.urlHintPre}
|
||||
<span className="font-mono">https://…</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={!looksLikeUrl} type="submit">
|
||||
Attach
|
||||
{c.attach}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { stopVoicePlayback } from '@/lib/voice-playback'
|
||||
@@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
|
||||
}
|
||||
|
||||
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (state.status === 'idle') {
|
||||
return null
|
||||
}
|
||||
|
||||
const recording = state.status === 'recording'
|
||||
const title = recording ? 'Dictating' : 'Transcribing'
|
||||
const title = recording ? t.composer.dictating : t.composer.transcribing
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -201,6 +204,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
}
|
||||
|
||||
export function VoicePlaybackActivity() {
|
||||
const { t } = useI18n()
|
||||
const playback = useStore($voicePlayback)
|
||||
|
||||
if (playback.status === 'idle') {
|
||||
@@ -210,10 +214,10 @@ export function VoicePlaybackActivity() {
|
||||
const preparing = playback.status === 'preparing'
|
||||
|
||||
const title = preparing
|
||||
? 'Preparing audio'
|
||||
? t.composer.preparingAudio
|
||||
: playback.source === 'voice-conversation'
|
||||
? 'Speaking response'
|
||||
: 'Reading aloud'
|
||||
? t.composer.speakingResponse
|
||||
: t.composer.readingAloud
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -176,13 +177,13 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || '__no_workspace__'
|
||||
const label = baseName(path) || path || 'No workspace'
|
||||
const label = baseName(path) || path || noWorkspaceLabel
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
@@ -233,6 +234,8 @@ export function ChatSidebar({
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@@ -402,8 +405,8 @@ export function ChatSidebar({
|
||||
)
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, workspaceOrderIds]
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, s.noWorkspace, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const loadMoreForProfileGroup = useCallback(
|
||||
@@ -589,13 +592,15 @@ export function ChatSidebar({
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={item.label}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
|
||||
{s.nav[item.id] ?? item.label}
|
||||
</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
@@ -615,9 +620,9 @@ export function ChatSidebar({
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label="Search sessions"
|
||||
aria-label={s.searchAria}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search sessions…"
|
||||
placeholder={s.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
@@ -629,10 +634,10 @@ export function ChatSidebar({
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
emptyState={
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
No sessions match “{trimmedQuery}”.
|
||||
{s.noMatch(trimmedQuery)}
|
||||
</div>
|
||||
}
|
||||
label="Results"
|
||||
label={s.results}
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
@@ -653,7 +658,7 @@ export function ChatSidebar({
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
label={s.pinned}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
@@ -703,9 +708,9 @@ export function ChatSidebar({
|
||||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
|
||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
@@ -724,7 +729,7 @@ export function ChatSidebar({
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label="Sessions"
|
||||
label={s.sessions}
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
@@ -795,19 +800,25 @@ function SidebarSessionSkeletons() {
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarAllPinnedState = () => (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
function SidebarAllPinnedState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
{t.sidebar.allPinned}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift-click a chat to pin</span>
|
||||
<span>{t.sidebar.shiftClickHint}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1006,6 +1017,8 @@ function SidebarWorkspaceGroup({
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarWorkspaceGroupProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const isProfileGroup = group.mode === 'profile'
|
||||
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
|
||||
const [open, setOpen] = useState(true)
|
||||
@@ -1052,9 +1065,9 @@ function SidebarWorkspaceGroup({
|
||||
/>
|
||||
</button>
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<Tip label={`New session in ${group.label}`}>
|
||||
<Tip label={s.newSessionIn(group.label)}>
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
aria-label={s.newSessionIn(group.label)}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
// Profile groups start a fresh session in that profile but keep the
|
||||
// all-profiles browse view (newSessionInProfile leaves the scope
|
||||
@@ -1069,7 +1082,7 @@ function SidebarWorkspaceGroup({
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={`Reorder workspace ${group.label}`}
|
||||
aria-label={s.reorderWorkspace(group.label)}
|
||||
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
@@ -1091,9 +1104,9 @@ function SidebarWorkspaceGroup({
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
) : (
|
||||
<Tip label={`Show ${nextCount} more in ${group.label}`}>
|
||||
<Tip label={s.showMoreIn(nextCount, group.label)}>
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
aria-label={s.showMoreIn(nextCount, group.label)}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
type="button"
|
||||
@@ -1144,7 +1157,8 @@ interface SidebarLoadMoreRowProps {
|
||||
}
|
||||
|
||||
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
|
||||
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
|
||||
const { t } = useI18n()
|
||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -43,13 +44,15 @@ interface ItemSpec {
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
{
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? 'Unpin' : 'Pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
@@ -58,17 +61,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'copy',
|
||||
label: 'Copy ID',
|
||||
label: r.copyId,
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: 'Export',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
@@ -77,7 +80,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: 'Rename',
|
||||
label: r.rename,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
@@ -86,7 +89,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
label: r.archive,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
@@ -96,7 +99,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
icon: 'trash',
|
||||
label: 'Delete',
|
||||
label: t.common.delete,
|
||||
onSelect: () => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
@@ -132,6 +135,7 @@ interface SessionActionsMenuProps
|
||||
}
|
||||
|
||||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
@@ -140,7 +144,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={`Actions for ${actions.title}`}
|
||||
aria-label={t.sidebar.row.actionsFor(actions.title)}
|
||||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
@@ -157,13 +161,14 @@ interface SessionContextMenuProps extends SessionActions {
|
||||
}
|
||||
|
||||
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
|
||||
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
|
||||
{renderItems(ContextMenuItem)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -181,6 +186,8 @@ interface RenameSessionDialogProps {
|
||||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [value, setValue] = useState(currentTitle)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -211,10 +218,10 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
const result = await renameSession(sessionId, next, profile)
|
||||
const finalTitle = result.title || next || ''
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
|
||||
notify({ durationMs: 2_000, kind: 'success', message: r.renamed })
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Rename failed')
|
||||
notifyError(err, r.renameFailed)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -224,8 +231,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename session</DialogTitle>
|
||||
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
|
||||
<DialogTitle>{r.renameTitle}</DialogTitle>
|
||||
<DialogDescription>{r.renameDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -239,16 +246,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Untitled session"
|
||||
placeholder={r.untitledPlaceholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void submit()} type="button">
|
||||
Save
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
const AGE_TICKS: ReadonlyArray<[number, string]> = [
|
||||
[86_400_000, 'd'],
|
||||
[3_600_000, 'h'],
|
||||
[60_000, 'm']
|
||||
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
|
||||
[86_400_000, 'ageDay'],
|
||||
[3_600_000, 'ageHour'],
|
||||
[60_000, 'ageMin']
|
||||
]
|
||||
|
||||
function formatAge(seconds: number): string {
|
||||
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
||||
const delta = Math.max(0, Date.now() - seconds * 1000)
|
||||
|
||||
for (const [ms, suffix] of AGE_TICKS) {
|
||||
for (const [ms, key] of AGE_TICKS) {
|
||||
if (delta >= ms) {
|
||||
return `${Math.floor(delta / ms)}${suffix}`
|
||||
return `${Math.floor(delta / ms)}${r[key]}`
|
||||
}
|
||||
}
|
||||
|
||||
return 'now'
|
||||
return r.ageNow
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
@@ -61,8 +62,10 @@ export function SidebarSessionRow({
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarSessionRowProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const age = formatAge(session.last_active || session.started_at, r)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
@@ -196,10 +199,10 @@ export function SidebarSessionRow({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
aria-label={r.actionsFor(title)}
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
title={r.sessionActions}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
@@ -220,6 +223,9 @@ function SidebarRowDot({
|
||||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// "Needs input" wins over "working": a clarify-blocked session is technically
|
||||
// still running, but the actionable state is that it's waiting on the user.
|
||||
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
||||
@@ -227,17 +233,17 @@ function SidebarRowDot({
|
||||
if (needsInput) {
|
||||
return (
|
||||
<span
|
||||
aria-label="Needs your input"
|
||||
aria-label={r.needsInput}
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title="Waiting for your answer"
|
||||
title={r.waitingForAnswer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking
|
||||
|
||||
Reference in New Issue
Block a user