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
Binary file modified docs/assets/demo_termflow.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions packages/client-ui/src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,41 @@ describe('shared application routes', () => {
expect(wrapper.find('[data-action="logout"]').exists()).toBe(true)
wrapper.unmount()
})

it('toggles UI language between Chinese and English via header locale switcher', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: clientRoutes })
await router.push('/')
await router.isReady()
const clientUi = createClientUi(createFakeRuntime())
await clientUi.session.loginWithToken('test-only-token')
const wrapper = mount(App, { global: { plugins: [router, clientUi] } })
await flushPromises()

const toggleBtn = wrapper.get('[data-action="toggle-locale"]')
expect(toggleBtn.text()).toContain('EN')
expect(wrapper.get('.side-nav a[href="/"]').text()).toBe('控制中心')
expect(wrapper.get('.side-nav a[href="/computers"]').text()).toBe('电脑管理')
expect(wrapper.get('.side-nav a[href="/settings"]').text()).toBe('设置')
expect(wrapper.get('h1').text()).toBe('控制中心')

await toggleBtn.trigger('click')
await flushPromises()

expect(window.localStorage.getItem('termflow_lang')).toBe('en')
expect(toggleBtn.text()).toContain('中文')
expect(wrapper.get('.side-nav a[href="/"]').text()).toBe('Dashboard')
expect(wrapper.get('.side-nav a[href="/computers"]').text()).toBe('Computers')
expect(wrapper.get('.side-nav a[href="/settings"]').text()).toBe('Settings')
expect(wrapper.get('h1').text()).toBe('Dashboard')
expect(wrapper.get('[data-action="logout"]').attributes('aria-label')).toBe('Sign Out')

await toggleBtn.trigger('click')
await flushPromises()

expect(window.localStorage.getItem('termflow_lang')).toBe('zh')
expect(toggleBtn.text()).toContain('EN')
expect(wrapper.get('.side-nav a[href="/"]').text()).toBe('控制中心')
expect(wrapper.get('h1').text()).toBe('控制中心')
wrapper.unmount()
})
})
45 changes: 29 additions & 16 deletions packages/client-ui/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,42 +1,53 @@
<template>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<a class="skip-link" href="#main-content">{{ t('app.skipToMain') }}</a>
<div class="app-shell" :class="{ 'is-terminal': terminalLayout, 'is-bare': bareLayout }">
<header v-if="!terminalLayout && !bareLayout" class="app-header">
<RouterLink class="brand" to="/" aria-label="TermFlow 控制中心">TermFlow</RouterLink>
<span class="header-context">远程终端控制</span>
<RouterLink class="brand" to="/" :aria-label="t('app.brandAria')">TermFlow</RouterLink>
<span class="header-context">{{ t('app.headerContext') }}</span>
<ThemePicker />
<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>
<button
v-if="sessionState.authenticated"
data-action="logout"
class="text-button logout-button"
type="button"
aria-label="退出登录"
title="退出登录"
:aria-label="t('app.logout')"
:title="t('app.logout')"
@click="logout"
>
<LogOut :size="18" aria-hidden="true" />
</button>
</header>
<aside v-if="!terminalLayout && !bareLayout" class="side-nav" aria-label="主导航">
<RouterLink to="/" aria-label="控制中心"><LayoutDashboard :size="18" aria-hidden="true" /><span class="nav-label">控制中心</span></RouterLink>
<RouterLink to="/computers" aria-label="电脑管理"><MonitorCog :size="18" aria-hidden="true" /><span class="nav-label">电脑管理</span></RouterLink>
<RouterLink to="/settings" aria-label="设置"><Settings :size="18" aria-hidden="true" /><span class="nav-label">设置</span></RouterLink>
<RouterLink v-if="agentBrokerEnabled" to="/agent" aria-label="Agent 控制台"><Bot :size="18" aria-hidden="true" /><span class="nav-label">Agent 控制台</span></RouterLink>
<aside v-if="!terminalLayout && !bareLayout" class="side-nav" :aria-label="t('nav.mainAria')">
<RouterLink to="/" :aria-label="t('nav.dashboard')"><LayoutDashboard :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.dashboard') }}</span></RouterLink>
<RouterLink to="/computers" :aria-label="t('nav.computers')"><MonitorCog :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.computers') }}</span></RouterLink>
<RouterLink to="/settings" :aria-label="t('nav.settings')"><Settings :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.settings') }}</span></RouterLink>
<RouterLink v-if="agentBrokerEnabled" to="/agent" :aria-label="t('nav.agent')"><Bot :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.agent') }}</span></RouterLink>
</aside>
<main id="main-content" tabindex="-1"><RouterView :key="routeViewKey" /></main>
<nav v-if="!terminalLayout && !bareLayout" class="mobile-nav" :class="{ 'mobile-nav--agent': agentBrokerEnabled }" aria-label="移动端导航">
<RouterLink to="/" aria-label="控制中心"><LayoutDashboard :size="18" aria-hidden="true" /><span class="nav-label">控制中心</span></RouterLink>
<RouterLink to="/computers" aria-label="电脑管理"><MonitorCog :size="18" aria-hidden="true" /><span class="nav-label">电脑管理</span></RouterLink>
<RouterLink to="/settings" aria-label="设置"><Settings :size="18" aria-hidden="true" /><span class="nav-label">设置</span></RouterLink>
<RouterLink v-if="agentBrokerEnabled" to="/agent" aria-label="Agent 控制台"><Bot :size="18" aria-hidden="true" /><span class="nav-label">Agent 控制台</span></RouterLink>
<nav v-if="!terminalLayout && !bareLayout" class="mobile-nav" :class="{ 'mobile-nav--agent': agentBrokerEnabled }" :aria-label="t('nav.mobileAria')">
<RouterLink to="/" :aria-label="t('nav.dashboard')"><LayoutDashboard :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.dashboard') }}</span></RouterLink>
<RouterLink to="/computers" :aria-label="t('nav.computers')"><MonitorCog :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.computers') }}</span></RouterLink>
<RouterLink to="/settings" :aria-label="t('nav.settings')"><Settings :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.settings') }}</span></RouterLink>
<RouterLink v-if="agentBrokerEnabled" to="/agent" :aria-label="t('nav.agent')"><Bot :size="18" aria-hidden="true" /><span class="nav-label">{{ t('nav.agent') }}</span></RouterLink>
</nav>
<BottomToast />
<AgentSensitiveReauthDialog />
</div>
</template>

<script setup lang="ts">
import { Bot, LayoutDashboard, LogOut, MonitorCog, Settings } from '@lucide/vue'
import { Bot, Globe, LayoutDashboard, LogOut, MonitorCog, Settings } from '@lucide/vue'
import { computed, onMounted, watch } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import ThemePicker from './components/settings/ThemePicker.vue'
Expand All @@ -45,7 +56,9 @@ import AgentSensitiveReauthDialog from './components/agent/AgentSensitiveReauthD
import { useAgentBroker } from './composables/useAgentBroker'
import { useSession } from './composables/useSession'
import { useTerminalPageLock } from './composables/useTerminalPageLock'
import { useI18n } from './i18n'

const { t, toggleLocale } = useI18n()
const router = useRouter()
const route = useRoute()
const { logoutSession, refreshSession, sessionState } = useSession()
Expand Down
45 changes: 25 additions & 20 deletions packages/client-ui/src/components/agent/AgentApprovalCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@
</div>
<div v-if="detail !== null" class="agent-approval-card__detail">
<p class="agent-approval-card__field"><span class="agent-approval-card__label">Pane</span><span data-agent-approval-pane>{{ pane }}</span></p>
<p class="agent-approval-card__field"><span class="agent-approval-card__label">操作</span><span data-agent-approval-operation>{{ operation }}</span></p>
<p class="agent-approval-card__field"><span class="agent-approval-card__label">{{ t('agent.approval.operation') }}</span><span data-agent-approval-operation>{{ operation }}</span></p>
<div class="agent-approval-card__summary" data-agent-approval-summary>
<AgentCollapsibleOutput v-if="isDiff(summary)" :content="summary" />
<span v-else>{{ summary }}</span>
</div>
<p class="agent-approval-card__hash" :data-agent-approval-hash="detail.canonical_hash">摘要 #{{ detail.canonical_hash.slice(0, 8) }}</p>
<p class="agent-approval-card__hash" :data-agent-approval-hash="detail.canonical_hash">{{ t('agent.approval.hashPrefix', { hash: detail.canonical_hash.slice(0, 8) }) }}</p>
</div>
<p v-else-if="detailFailed" class="agent-approval-card__fallback" data-agent-approval-detail-failed-text>详情不可用</p>
<p v-else-if="detailFailed" class="agent-approval-card__fallback" data-agent-approval-detail-failed-text>{{ t('common.detailUnavailable') }}</p>
<footer class="agent-approval-card__footer">
<span v-if="expiry !== null" class="agent-approval-card__expires" data-agent-approval-expires>有效期至 {{ expiry }}</span>
<button type="button" class="agent-approval-card__action" data-action="focus-approval" @click="emit('focus', permission.approvalId)">在审批面板处理</button>
<span v-if="expiry !== null" class="agent-approval-card__expires" data-agent-approval-expires>{{ t('agent.approval.expiresPrefix', { expiry }) }}</span>
<button type="button" class="agent-approval-card__action" data-action="focus-approval" @click="emit('focus', permission.approvalId)">{{ t('agent.approval.focusAction') }}</button>
</footer>
</article>
</template>
Expand Down Expand Up @@ -56,11 +56,13 @@ import {
hasPendingApprovalListLoads,
whenApprovalListLoadsSettled,
} from '../../composables/useAgentApprovals'
import { useI18n } from '../../i18n'
import { useClientRuntime } from '../../runtime'
import { assessRisk, type RiskAssessment } from '../../utils/risk'
import { isUnifiedDiff } from '../../utils/diff'
import AgentCollapsibleOutput from './AgentCollapsibleOutput.vue'

const { t } = useI18n()
const props = defineProps<{
/** CUSTOM-event-derived record, exactly as tracked by the history reducer. */
permission: AgentPermissionState
Expand Down Expand Up @@ -105,25 +107,28 @@ async function resolveDetail(approvalId: string): Promise<ApprovalResponse> {
return fetched
}

const STATE_LABELS: Record<string, string> = {
pending: '待处理',
approved: '已批准',
denied: '已拒绝',
expired: '已过期',
revoked: '已撤销',
consumed: '已消费',
const STATE_LABEL_KEYS: Record<string, string> = {
pending: 'agent.approval.state.pending',
approved: 'agent.approval.state.approved',
denied: 'agent.approval.state.denied',
expired: 'agent.approval.state.expired',
revoked: 'agent.approval.state.revoked',
consumed: 'agent.approval.state.consumed',
}

/** REST detail is authoritative once loaded; the CUSTOM record fills in before/on failure. */
const rawState = computed(() => detail.value?.state ?? props.permission.state)
const stateLabel = computed(() => STATE_LABELS[rawState.value] ?? '未知')
const tool = computed(() => (props.permission.toolName !== null ? stripAnsiOsc(props.permission.toolName) : '工具调用'))
const stateLabel = computed(() => {
const key = STATE_LABEL_KEYS[rawState.value]
return key ? t(key) : t('common.unknown')
})
const tool = computed(() => (props.permission.toolName !== null ? stripAnsiOsc(props.permission.toolName) : t('agent.chat.toolCall')))
const evidence = computed(() => (props.permission.evidence !== null ? stripAnsiOsc(props.permission.evidence) : null))
const pane = computed(() => (detail.value?.pane_id !== null && detail.value?.pane_id !== undefined ? stripAnsiOsc(detail.value.pane_id) : '详情不可用'))
const operation = computed(() => (detail.value?.operation !== null && detail.value?.operation !== undefined ? stripAnsiOsc(detail.value.operation) : '详情不可用'))
const pane = computed(() => (detail.value?.pane_id !== null && detail.value?.pane_id !== undefined ? stripAnsiOsc(detail.value.pane_id) : t('common.detailUnavailable')))
const operation = computed(() => (detail.value?.operation !== null && detail.value?.operation !== undefined ? stripAnsiOsc(detail.value.operation) : t('common.detailUnavailable')))
const summary = computed(() => {
const value = detail.value?.intent_summary
return value !== null && value !== undefined ? stripAnsiOsc(value) : '详情不可用'
return value !== null && value !== undefined ? stripAnsiOsc(value) : t('common.detailUnavailable')
})
const expiry = computed(() => {
const iso = detail.value?.expires_at ?? props.permission.expiresAt
Expand All @@ -140,9 +145,9 @@ function formatExpiry(iso: string): string {

const isDiff = (text: string | null) => text !== null && isUnifiedDiff(text)
const risk = computed<RiskAssessment>(() => assessRisk({
operation: operation.value !== '详情不可用' ? operation.value : null,
summary: summary.value !== '详情不可用' ? summary.value : null,
toolName: tool.value !== '工具调用' ? tool.value : null,
operation: detail.value?.operation !== null && detail.value?.operation !== undefined ? stripAnsiOsc(detail.value.operation) : null,
summary: detail.value?.intent_summary !== null && detail.value?.intent_summary !== undefined ? stripAnsiOsc(detail.value.intent_summary) : null,
toolName: props.permission.toolName !== null ? stripAnsiOsc(props.permission.toolName) : null,
evidence: evidence.value,
}))
</script>
Loading
Loading