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
7 changes: 5 additions & 2 deletions apps/clients/web/src/adapters/browserThemePreferences.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { THEME_STORAGE_KEY } from '@termflow/client-ui'
import { LOCALE_STORAGE_KEY, setLocale, THEME_STORAGE_KEY } from '@termflow/client-ui'
import { createBrowserThemePreferences, createBrowserThemeTarget } from './browserThemePreferences'

describe('browser theme adapters', () => {
it('persists only a validated theme identifier', () => {
it('persists only a validated theme identifier and wires locale preferences', () => {
const storage = { getItem: vi.fn(() => 'cloud-cobalt'), setItem: vi.fn() }
const preferences = createBrowserThemePreferences(storage)
expect(preferences.load()).toBe('cloud-cobalt')
preferences.save('midnight-indigo')
expect(storage.setItem).toHaveBeenCalledWith(THEME_STORAGE_KEY, 'midnight-indigo')
setLocale('en')
expect(storage.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'en')
setLocale('zh')
})

it('rejects unknown stored values and applies through the injected root element', () => {
Expand Down
12 changes: 12 additions & 0 deletions apps/clients/web/src/adapters/browserThemePreferences.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
configureLocalePreferences,
isThemeId,
LOCALE_STORAGE_KEY,
THEME_STORAGE_KEY,
type Locale,
type ThemeId,
type ThemePreferences,
type ThemeTarget,
Expand All @@ -10,6 +13,15 @@ type ThemeStorage = Pick<Storage, 'getItem' | 'setItem'>
type ThemeRoot = Pick<HTMLElement, 'dataset'>

export function createBrowserThemePreferences(storage: ThemeStorage = globalThis.localStorage): ThemePreferences {
configureLocalePreferences({
load() {
const v = storage.getItem(LOCALE_STORAGE_KEY)
return v === 'en' || v === 'zh' ? v : null
},
save(loc: Locale) {
storage.setItem(LOCALE_STORAGE_KEY, loc)
},
})
return {
load() {
const stored = storage.getItem(THEME_STORAGE_KEY)
Expand Down
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.
18 changes: 11 additions & 7 deletions packages/client-core/src/http/apiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ const safeMessagesEn: Record<ApiErrorKind, string> = {
http_capability_denied: 'Client network capability configuration is invalid. Please update or reinstall TermFlow.',
}

function isEnglishLocale(): boolean {
try {
return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function' && localStorage.getItem('termflow_lang') === 'en'
} catch {
return false
}
export type ApiErrorLocale = 'zh' | 'en'

let currentApiErrorLocale: ApiErrorLocale = 'zh'

export function setApiErrorLocale(locale: ApiErrorLocale): void {
currentApiErrorLocale = locale
}

export function getApiErrorLocale(): ApiErrorLocale {
return currentApiErrorLocale
}

export function safeMessageFor(kind: ApiErrorKind): string {
return isEnglishLocale() ? safeMessagesEn[kind] : safeMessagesZh[kind]
return currentApiErrorLocale === 'en' ? safeMessagesEn[kind] : safeMessagesZh[kind]
}

export interface ApiErrorDetails {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ const bindingLabel = computed(() => {
const binding = detail.value?.binding
return binding === undefined
? t('agent.conversations.defaultTitle', { id: props.conversationId.slice(0, 8) })
: `Binding ${binding.binding_id} · Term ${binding.term_id}`
: t('agent.chat.bindingLabel', { bindingId: binding.binding_id, termId: binding.term_id })
})

/**
Expand Down
21 changes: 13 additions & 8 deletions packages/client-ui/src/components/computers/ComputerNameEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,34 @@
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { ApiError } from '@termflow/client-core'
import { computed, ref, watch } from 'vue'
import { ApiError, safeMessageFor, type ApiErrorKind } from '@termflow/client-core'
import { useI18n } from '../../i18n'
import { useClientRuntime } from '../../runtime'

const { t } = useI18n()
const { t, locale } = useI18n()
const props = defineProps<{ computerId: string; displayName: string }>()
const emit = defineEmits<{ updated: [name: string] }>()
const runtime = useClientRuntime()
const editing = ref(false)
const busy = ref(false)
const draft = ref(props.displayName)
const currentName = ref(props.displayName)
const message = ref('')
const errorState = ref<{ type: 'api'; kind: ApiErrorKind } | { type: 'key'; key: string } | null>(null)
const message = computed(() => {
void locale.value
if (errorState.value === null) return ''
return errorState.value.type === 'api' ? safeMessageFor(errorState.value.kind) : t(errorState.value.key)
})
watch(() => props.displayName, (value) => { currentName.value = value; if (!editing.value) draft.value = value })

function validName(value: string) {
return value.length >= 1 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value)
}
function begin() { draft.value = currentName.value; message.value = ''; editing.value = true }
function cancel() { draft.value = currentName.value; message.value = ''; editing.value = false }
function begin() { draft.value = currentName.value; errorState.value = null; editing.value = true }
function cancel() { draft.value = currentName.value; errorState.value = null; editing.value = false }
async function save() {
if (!validName(draft.value)) { message.value = t('computers.nameEditor.invalid'); return }
if (!validName(draft.value)) { errorState.value = { type: 'key', key: 'computers.nameEditor.invalid' }; return }
const previous = currentName.value
currentName.value = draft.value
editing.value = false
Expand All @@ -46,7 +51,7 @@ async function save() {
} catch (error) {
currentName.value = previous
editing.value = true
message.value = error instanceof ApiError ? error.message : t('computers.nameEditor.saveError')
errorState.value = error instanceof ApiError ? { type: 'api', kind: error.kind } : { type: 'key', key: 'computers.nameEditor.saveError' }
} finally { busy.value = false }
}
</script>
19 changes: 12 additions & 7 deletions packages/client-ui/src/components/computers/EnrollmentDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@
<script setup lang="ts">
import { CircleHelp, Copy, X } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ApiError } from '@termflow/client-core'
import { ApiError, safeMessageFor, type ApiErrorKind } from '@termflow/client-core'
import { useI18n } from '../../i18n'
import { useClientRuntime } from '../../runtime'

const { t } = useI18n()
const { t, locale } = useI18n()
const emit = defineEmits<{ closed: []; added: [] }>()
const runtime = useClientRuntime()
const open = ref(true)
Expand All @@ -53,7 +53,12 @@ const code = ref<string | null>(null)
const command = ref('')
const expiresAt = ref<number | null>(null)
const now = ref(runtime.clock.now())
const message = ref('')
const errorState = ref<{ type: 'api'; kind: ApiErrorKind } | { type: 'key'; key: string } | null>(null)
const message = computed(() => {
void locale.value
if (errorState.value === null) return ''
return errorState.value.type === 'api' ? safeMessageFor(errorState.value.kind) : t(errorState.value.key)
})
const panel = ref<HTMLElement | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const copyButton = ref<HTMLButtonElement | null>(null)
Expand Down Expand Up @@ -109,7 +114,7 @@ async function pollForEnrollment() {
open.value = false
emit('added')
void nextTick(() => { if (returnFocus?.isConnected) returnFocus.focus() })
} catch (error) { if (open.value) message.value = error instanceof ApiError ? error.message : t('computers.enrollment.pollError') }
} catch (error) { if (open.value) errorState.value = error instanceof ApiError ? { type: 'api', kind: error.kind } : { type: 'key', key: 'computers.enrollment.pollError' } }
finally { pollingEnrollment = false }
}
function startEnrollmentPolling() {
Expand All @@ -122,11 +127,11 @@ function validName(value: string) {
async function create(automatic = false) {
if (busy.value || !open.value) return
if (!validName(displayName.value)) {
message.value = t('computers.enrollment.nameInvalid')
errorState.value = { type: 'key', key: 'computers.enrollment.nameInvalid' }
return
}
busy.value = true
message.value = ''
errorState.value = null
try {
const baseline = await runtime.api.computers.list()
if (!open.value) return
Expand All @@ -141,7 +146,7 @@ async function create(automatic = false) {
startTimer()
startEnrollmentPolling()
if (!automatic) void nextTick(() => copyButton.value?.focus())
} catch (error) { message.value = error instanceof ApiError ? error.message : automatic ? t('computers.enrollment.refreshError') : t('computers.enrollment.createError') }
} catch (error) { errorState.value = error instanceof ApiError ? { type: 'api', kind: error.kind } : { type: 'key', key: automatic ? 'computers.enrollment.refreshError' : 'computers.enrollment.createError' } }
finally { busy.value = false }
}
async function copy() { if (!command.value) return; await runtime.clipboard.writeText(command.value); copied.value = true }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,32 @@

<script setup lang="ts">
import type { NativeClientResponse } from '@termflow/client-contracts'
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useBottomToast } from '../../composables/useBottomToast'
import { useI18n } from '../../i18n'
import { useClientRuntime } from '../../runtime'
import DeviceAuthorizationApprovalDialog from './DeviceAuthorizationApprovalDialog.vue'

const { t } = useI18n()
const { t, locale } = useI18n()
defineProps<{ totpEnabled: boolean }>()
const runtime = useClientRuntime()
const toast = useBottomToast()
const clients = ref<NativeClientResponse[]>([])
const selected = ref<NativeClientResponse | null>(null)
const adminToken = ref('')
const totpCode = ref('')
const error = ref('')
const errorKey = ref('')
const error = computed(() => {
void locale.value
return errorKey.value ? t(errorKey.value) : ''
})
const approvalOpen = ref(false)
const revokePanel = ref<HTMLElement | null>(null)
const revokeInput = ref<HTMLInputElement | null>(null)
let restoreFocus: HTMLElement | null = null

async function load() { clients.value = (await runtime.api.clients.list()).clients.filter((client) => client.revoked_at === null) }
function clearSecrets() { adminToken.value = ''; totpCode.value = ''; error.value = '' }
function clearSecrets() { adminToken.value = ''; totpCode.value = ''; errorKey.value = '' }
function cancel() {
selected.value = null
clearSecrets()
Expand All @@ -72,15 +76,15 @@ function trapFocus(event: KeyboardEvent) {
}
}
async function openRevoke(client: NativeClientResponse) {
error.value = ''
errorKey.value = ''
selected.value = client
restoreFocus = document.activeElement as HTMLElement | null
await nextTick()
revokeInput.value?.focus()
}
async function removeSelected() {
if (selected.value === null) return
error.value = ''
errorKey.value = ''
try {
await runtime.api.clients.remove(selected.value.client_id, { adminToken: adminToken.value, ...(totpCode.value ? { totpCode: totpCode.value } : {}) })
const removed = selected.value.client_id
Expand All @@ -89,7 +93,7 @@ async function removeSelected() {
clients.value = clients.value.filter((client) => client.client_id !== removed)
toast.show({ text: t('settings.clients.revokedToast'), tone: 'success' })
} catch {
error.value = t('settings.clients.revokeError')
errorKey.value = 'settings.clients.revokeError'
}
}
async function onApproved() { approvalOpen.value = false; await load() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@
<script setup lang="ts">
import type { TotpStatusResponse } from '@termflow/client-contracts'
import { X } from '@lucide/vue'
import { nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from '../../i18n'
import { useClientRuntime } from '../../runtime'

const { t } = useI18n()
const { t, locale } = useI18n()
const props = defineProps<{
open: boolean
targetEnabled: boolean
Expand All @@ -40,14 +40,18 @@ const emit = defineEmits<{ close: []; confirmed: [TotpStatusResponse] }>()
const runtime = useClientRuntime()
const adminToken = ref('')
const code = ref('')
const message = ref('')
const errorKey = ref('')
const message = computed(() => {
void locale.value
return errorKey.value ? t(errorKey.value) : ''
})
const busy = ref(false)
const adminInput = ref<HTMLInputElement | null>(null)

function clearSecrets() {
adminToken.value = ''
code.value = ''
message.value = ''
errorKey.value = ''
}

function restoreFocus() {
Expand All @@ -71,7 +75,7 @@ function onKeydown(event: KeyboardEvent) {
async function submit() {
if (busy.value) return
busy.value = true
message.value = ''
errorKey.value = ''
try {
const credentials = { adminToken: adminToken.value, totpCode: code.value }
const status = props.targetEnabled
Expand All @@ -82,7 +86,7 @@ async function submit() {
emit('close')
restoreFocus()
} catch {
message.value = t('settings.totp.verifyFailed')
errorKey.value = 'settings.totp.verifyFailed'
} finally {
busy.value = false
}
Expand Down
19 changes: 13 additions & 6 deletions packages/client-ui/src/composables/useDashboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { ApiError } from '@termflow/client-core'
import { t } from '../i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ApiError, safeMessageFor, type ApiErrorKind } from '@termflow/client-core'
import { locale, t } from '../i18n'
import { useClientRuntime } from '../runtime'
import type { DashboardSnapshot } from '../types'

Expand All @@ -10,7 +10,12 @@ export function useDashboard() {
const runtime = useClientRuntime()
const snapshot = ref<DashboardSnapshot | null>(null)
const loading = ref(true)
const message = ref('')
const errorState = ref<{ type: 'api'; kind: ApiErrorKind } | { type: 'key'; key: string } | null>(null)
const message = computed(() => {
void locale.value
if (errorState.value === null) return ''
return errorState.value.type === 'api' ? safeMessageFor(errorState.value.kind) : t(errorState.value.key)
})
let controller: AbortController | null = null
let timer: unknown | null = null
let unsubscribeVisibility: (() => void) | null = null
Expand All @@ -27,9 +32,11 @@ export function useDashboard() {
controller = new AbortController()
try {
snapshot.value = await runtime.api.dashboard.get(controller.signal)
message.value = ''
errorState.value = null
} catch (error) {
if (!(error instanceof ApiError) || error.kind !== 'aborted') message.value = error instanceof ApiError ? error.message : t('dashboard.loadError')
if (!(error instanceof ApiError) || error.kind !== 'aborted') {
errorState.value = error instanceof ApiError ? { type: 'api', kind: error.kind } : { type: 'key', key: 'dashboard.loadError' }
}
} finally {
loading.value = false
clearTimer()
Expand Down
Loading
Loading