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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/client-ui/src/components/common/BottomToast.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createBottomToast } from '../../composables/useBottomToast'
import { setLocale, t } from '../../i18n'
import { bottomToastKey } from '../../runtimeKey'
import BottomToast from './BottomToast.vue'

describe('BottomToast', () => {
Expand All @@ -10,4 +14,35 @@ describe('BottomToast', () => {
expect(wrapper.get('[data-bottom-toast]').attributes('role')).toBe('status')
expect(wrapper.get('[data-bottom-toast]').attributes('data-tone')).toBe('success')
})

it('reactively translates visible bottom toast when locale toggles between zh and en', async () => {
setLocale('zh')
const controller = createBottomToast({
now: () => 0,
setTimeout: () => 1,
clearTimeout: () => {},
setInterval: () => 1,
clearInterval: () => {},
})
controller.show({ text: () => t('agent.chat.forbiddenToast'), tone: 'error' })

const wrapper = mount(BottomToast, {
global: {
provide: {
[bottomToastKey as symbol]: controller,
},
},
})

expect(wrapper.get('[data-bottom-toast]').text()).toBe('当前客户端没有访问该会话的权限,请重新登录授权。')
setLocale('en')
await nextTick()
try {
expect(wrapper.get('[data-bottom-toast]').text()).toBe('This client does not have permission to access this session. Please sign in and authorize again.')
} finally {
setLocale('zh')
await nextTick()
}
expect(wrapper.get('[data-bottom-toast]').text()).toBe('当前客户端没有访问该会话的权限,请重新登录授权。')
})
})
5 changes: 4 additions & 1 deletion packages/client-ui/src/components/common/BottomToast.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { BottomToastMessage, BottomToastTone } from '../../composables/useBottomToast'
import { useI18n } from '../../i18n'
import { bottomToastKey } from '../../runtimeKey'

const props = defineProps<{
message?: string
tone?: BottomToastTone
}>()

const { locale, translateDynamicText } = useI18n()
const controller = inject(bottomToastKey, undefined)
const active = computed<BottomToastMessage | null>(() => {
if (props.message !== undefined) return { text: props.message, tone: props.tone ?? 'success' }
void locale.value
if (props.message !== undefined) return { text: translateDynamicText(props.message), tone: props.tone ?? 'success' }
return controller?.current.value ?? null
})
</script>
6 changes: 3 additions & 3 deletions packages/client-ui/src/composables/useAgentConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,16 @@ export function useAgentConversation(options: UseAgentConversationOptions) {
}
if (info.code === AGENT_STREAM_CLOSE_NOT_FOUND) {
runtime.agentCursorStore.clear(options.conversationId)
toast.show({ text: t('agent.chat.notFoundToast'), tone: 'error' })
toast.show({ text: () => t('agent.chat.notFoundToast'), tone: 'error' })
return
}
if (info.code === AGENT_STREAM_CLOSE_FORBIDDEN) {
toast.show({ text: t('agent.chat.forbiddenToast'), tone: 'error' })
toast.show({ text: () => t('agent.chat.forbiddenToast'), tone: 'error' })
}
},
onError: (error) => {
if (error.code === 'stream_too_slow') {
toast.show({ text: t('agent.chat.recoveringToast'), tone: 'error' })
toast.show({ text: () => t('agent.chat.recoveringToast'), tone: 'error' })
}
},
onAuthenticationRequired: handleAuthenticationRequired,
Expand Down
7 changes: 6 additions & 1 deletion packages/client-ui/src/composables/useAgentConversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import type { AgentConversationResponse } from '@termflow/client-contracts'
import { ApiError, createApprovalsApi } from '@termflow/client-core'
import { onBeforeUnmount, onMounted, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
import { t } from '../i18n'
import { locale, t, translateDynamicText } from '../i18n'
import { useClientRuntime } from '../runtime'
import { useBottomToast } from './useBottomToast'

Expand All @@ -26,6 +26,11 @@ export function useAgentConversations(bindingId?: MaybeRefOrGetter<BindingScope>
const pendingCounts = ref<ReadonlyMap<string, number>>(new Map())
const loading = ref(true)
const errorMessage = ref<string | null>(null)
watch(locale, () => {
if (errorMessage.value !== null) {
errorMessage.value = translateDynamicText(errorMessage.value)
}
})
const approvalsApi = createApprovalsApi(runtime.api.request)
// List loads and mutations keep separate controllers: load() aborts its
// own in-flight list request on a binding switch, which must not cancel
Expand Down
29 changes: 22 additions & 7 deletions packages/client-ui/src/composables/useBottomToast.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { inject, ref, type Ref } from 'vue'
import { computed, inject, ref, type Ref } from 'vue'
import { locale, translateDynamicText } from '../i18n'
import type { ClockPort } from '../runtime'
import { bottomToastKey } from '../runtimeKey'

Expand All @@ -9,25 +10,39 @@ export interface BottomToastMessage {
readonly tone: BottomToastTone
}

export interface BottomToastInput {
readonly text: string | (() => string)
readonly tone: BottomToastTone
}

export interface BottomToastController {
readonly current: Readonly<Ref<BottomToastMessage | null>>
show(message: BottomToastMessage): void
show(message: BottomToastInput): void
clear(): void
}

export function createBottomToast(clock: ClockPort): BottomToastController {
const current = ref<BottomToastMessage | null>(null)
const rawCurrent = ref<BottomToastInput | null>(null)
const current = computed<BottomToastMessage | null>(() => {
void locale.value
if (rawCurrent.value === null) return null
const rawText = typeof rawCurrent.value.text === 'function' ? rawCurrent.value.text() : rawCurrent.value.text
return {
text: translateDynamicText(rawText),
tone: rawCurrent.value.tone,
}
})
let timer: unknown | null = null
function clear() {
if (timer !== null) clock.clearTimeout(timer)
timer = null
current.value = null
rawCurrent.value = null
}
function show(message: BottomToastMessage) {
function show(message: BottomToastInput) {
clear()
current.value = message
rawCurrent.value = message
timer = clock.setTimeout(() => {
current.value = null
rawCurrent.value = null
timer = null
}, 3_000)
}
Expand Down
22 changes: 22 additions & 0 deletions packages/client-ui/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ const messages: Record<Locale, Record<string, string>> = {
'login.totpHint': '(请输入验证器应用当前显示的 6 位验证码。)',
'login.submit': '登录',
'login.verifyAndLogin': '验证并登录',
'login.invalidToken': '管理员令牌无效,请核对后重试。',
'login.failed': '登录失败,请重试。',
'deviceAuth.eyebrow': '跨设备授权',
'deviceAuth.title': '授权新设备',
Expand Down Expand Up @@ -825,6 +826,7 @@ const messages: Record<Locale, Record<string, string>> = {
'login.totpHint': ' (Enter the 6-digit code from your authenticator app.)',
'login.submit': 'Sign In',
'login.verifyAndLogin': 'Verify & Sign In',
'login.invalidToken': 'Invalid admin token. Please check and try again.',
'login.failed': 'Sign-in failed. Please try again.',
'deviceAuth.eyebrow': 'Cross-device authorization',
'deviceAuth.title': 'Authorize New Device',
Expand Down Expand Up @@ -1122,6 +1124,25 @@ export function t(key: string, params?: Record<string, string | number>): string
})
}

const reverseKeyMap = new Map<string, string>()
for (const [key, val] of Object.entries(messages.zh)) {
if (!val.includes('{')) reverseKeyMap.set(val, key)
}
for (const [key, val] of Object.entries(messages.en)) {
if (!val.includes('{')) reverseKeyMap.set(val, key)
}

export function translateDynamicText(text: string): string {
if (!text) return text
const dict = messages[currentLocale.value] ?? messages.zh
if (dict[text] !== undefined) return dict[text]
const matchedKey = reverseKeyMap.get(text)
if (matchedKey !== undefined && dict[matchedKey] !== undefined) {
return dict[matchedKey]
}
return text
}

export function useI18n() {
const stored = readStoredLocale()
if (stored !== null && currentLocale.value !== stored) {
Expand All @@ -1133,5 +1154,6 @@ export function useI18n() {
setLocale,
toggleLocale,
t,
translateDynamicText,
}
}
117 changes: 67 additions & 50 deletions packages/client-ui/src/views/AgentPreviewView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
</div>
<div class="agent-chat-header__actions">
<span class="agent-backend-status agent-backend-status--busy">{{ t('agent.backend.busy') }}</span>
<button
data-action="toggle-locale"
class="text-button locale-toggle-button"
type="button"
:aria-label="t('app.switchLangAria')"
:title="t('app.switchLangAria')"
@click="toggleLocale"
>
<Globe :size="15" aria-hidden="true" />
<span class="locale-badge">{{ t('app.switchLangBadge') }}</span>
</button>
</div>
</header>
<div class="agent-chat-body">
Expand Down Expand Up @@ -50,7 +61,7 @@ import AgentApprovalCard from '../components/agent/AgentApprovalCard.vue'
import AgentPartsList, { type AgentConversationPart } from '../components/agent/AgentPartsList.vue'
import MobileKeyBar from '../components/terminal/MobileKeyBar.vue'
import ThemePicker from '../components/settings/ThemePicker.vue'
import { Send } from '@lucide/vue'
import { Globe, Send } from '@lucide/vue'
import { useSensitiveAuthorization } from '../composables/useSensitiveAuthorization'
import { useI18n } from '../i18n'
import {
Expand All @@ -62,64 +73,69 @@ import {
type AguiEvent,
} from '@termflow/client-core'

const { t } = useI18n()
const { t, locale, toggleLocale } = useI18n()
const authorization = useSensitiveAuthorization()
const modifierController = new MobileModifierController()
const lastKeyInput = ref('')
function handleKeyInput(bytes: Uint8Array) {
lastKeyInput.value = new TextDecoder().decode(bytes)
}
const now = Date.now()
let state = createAgentHistoryState()
state = seedUserMessages(
state,
[
const history = computed<AgentHistoryState>(() => {
void locale.value
let state = createAgentHistoryState()
state = seedUserMessages(
state,
[
{
message_id: 'u1',
conversation_id: 'c1',
run_id: null,
role: 'user',
kind: 'text',
assembly_revision: 1,
is_final: false,
body_digest: 'd',
body: t('agent.preview.sampleUserPrompt'),
created_at: new Date(now).toISOString(),
},
] as never,
() => now,
)

const frames: AguiEvent[] = [
{ type: 'RUN_STARTED', threadId: 'c1', runId: 'r1' },
{ type: 'STATE_DELTA', delta: [{ op: 'replace', path: '/backend', value: { state: 'busy', epoch: 1 } }] },
{ type: 'CUSTOM', name: 'termflow.thinking_delta', value: { id: 'th1', delta: t('agent.preview.sampleThinking1') } },
{ type: 'CUSTOM', name: 'termflow.thinking_completed', value: { id: 'th1' } },
{ type: 'TEXT_MESSAGE_CHUNK', messageId: 'a1', delta: t('agent.preview.sampleAssistant1') },
{ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'termflow_termflow_list_panes' },
{
message_id: 'u1',
conversation_id: 'c1',
run_id: null,
role: 'user',
kind: 'text',
assembly_revision: 1,
is_final: false,
body_digest: 'd',
body: t('agent.preview.sampleUserPrompt'),
created_at: new Date(now).toISOString(),
type: 'TOOL_CALL_RESULT',
messageId: 't1',
toolCallId: 't1',
content: '{"status":"success","truncated":false}',
},
] as never,
() => now,
)

const frames: AguiEvent[] = [
{ type: 'RUN_STARTED', threadId: 'c1', runId: 'r1' },
{ type: 'STATE_DELTA', delta: [{ op: 'replace', path: '/backend', value: { state: 'busy', epoch: 1 } }] },
{ type: 'CUSTOM', name: 'termflow.thinking_delta', value: { id: 'th1', delta: t('agent.preview.sampleThinking1') } },
{ type: 'CUSTOM', name: 'termflow.thinking_completed', value: { id: 'th1' } },
{ type: 'TEXT_MESSAGE_CHUNK', messageId: 'a1', delta: t('agent.preview.sampleAssistant1') },
{ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'termflow_termflow_list_panes' },
{
type: 'TOOL_CALL_RESULT',
messageId: 't1',
toolCallId: 't1',
content: '{"status":"success","truncated":false}',
},
{ type: 'TEXT_MESSAGE_END', messageId: 'a1' },
{ type: 'TEXT_MESSAGE_CHUNK', messageId: 'a2', delta: t('agent.preview.sampleAssistant2') },
{
type: 'CUSTOM',
name: 'termflow.permission_requested',
value: { approval_request_id: 'a0000000-0000-4000-8000-000000000001' },
},
] as AguiEvent[]

for (const frame of frames) state = applyAguiEvent(state, frame, () => now)
{ type: 'TEXT_MESSAGE_END', messageId: 'a1' },
{ type: 'TEXT_MESSAGE_CHUNK', messageId: 'a2', delta: t('agent.preview.sampleAssistant2') },
{
type: 'CUSTOM',
name: 'termflow.permission_requested',
value: { approval_request_id: 'a0000000-0000-4000-8000-000000000001' },
},
] as AguiEvent[]

const history: AgentHistoryState = state
const parts = computed<AgentConversationPart[]>(() => [
{ type: 'step-start' },
{ type: 'reasoning', text: t('agent.preview.sampleThinking1') },
{ type: 'tool', tool: 'termflow_termflow_list_panes', status: 'completed', input: '{}', output: '{\n "instance_id": "a38adddd-f204-4bd8-9587-0b57aa21524c",\n "panes": [{"pane_id": "%0", "active": true, "dead": false}]\n}' },
{ type: 'reasoning', text: t('agent.preview.sampleThinking2') },
for (const frame of frames) state = applyAguiEvent(state, frame, () => now)
return state
})
void history.value
const parts = computed<AgentConversationPart[]>(() => {
void locale.value
return [
{ type: 'step-start' },
{ type: 'reasoning', text: t('agent.preview.sampleThinking1') },
{ type: 'tool', tool: 'termflow_termflow_list_panes', status: 'completed', input: '{}', output: '{\n "instance_id": "a38adddd-f204-4bd8-9587-0b57aa21524c",\n "panes": [{"pane_id": "%0", "active": true, "dead": false}]\n}' },
{ type: 'reasoning', text: t('agent.preview.sampleThinking2') },
{
type: 'tool',
tool: 'termflow_termflow_git_diff',
Expand Down Expand Up @@ -149,7 +165,8 @@ const parts = computed<AgentConversationPart[]>(() => [
{ type: 'tool', tool: 'termflow_termflow_pane_read', status: 'completed', input: '{"params": {"pane_id": "%0"}}', output: '"$ echo 1\\n1\\n$ "' },
{ type: 'text', text: t('agent.preview.sampleFinalText') },
{ type: 'step-finish' },
])
]
})
const permission = {
approvalId: 'a0000000-0000-4000-8000-000000000001',
toolName: 'rm',
Expand Down
7 changes: 6 additions & 1 deletion packages/client-ui/src/views/AgentView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ import { useBottomToast } from '../composables/useBottomToast'
import { useI18n } from '../i18n'
import { useClientRuntime } from '../runtime'

const { t } = useI18n()
const { t, locale, translateDynamicText } = useI18n()
const runtime = useClientRuntime()
const toast = useBottomToast()
const { agentBrokerEnabled } = useAgentBroker()
Expand All @@ -171,6 +171,11 @@ const bindings = ref<AgentBindingResponse[]>([])
const termNames = ref(new Map<string, string>())
const directoryLoading = ref(true)
const directoryError = ref<string | null>(null)
watch(locale, () => {
if (directoryError.value !== null) {
directoryError.value = translateDynamicText(directoryError.value)
}
})
const controller = ref<AbortController | null>(null)

const conversations = useAgentConversations(() => bindings.value.map((binding) => binding.binding_id))
Expand Down
Loading
Loading