feat: support H5 mobile chat access

H5 browser use needs to keep local desktop WebUI testing open while giving remote phones a usable chat-first surface. This change scopes browser auth by origin and target host, hides non-chat desktop chrome on H5 mobile, compacts the empty-session composer, and unifies mobile selectors behind a full-width bottom sheet with an explicit close action.

Constraint: Local browser development may bypass H5 auth only for loopback or private LAN server hosts; public or remote hosts still require an H5 token.

Constraint: Mobile H5 scope is chat-first; settings and scheduled-task surfaces stay hidden instead of fully redesigned.

Rejected: Reuse desktop popovers on mobile | hover/anchored dropdown behavior leaves key actions hard to dismiss and cramped on narrow screens.

Rejected: Let any localhost origin bypass remote auth | it would let arbitrary local pages access public H5 servers without a token.

Confidence: medium

Scope-risk: moderate

Directive: Keep future H5 selector popups on MobileBottomSheet unless a selector needs a reviewed custom mobile interaction.

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run test -- src/pages/EmptySession.test.tsx src/components/chat/ChatInput.test.tsx src/lib/desktopRuntime.test.ts src/components/shared/RepositoryLaunchControls.test.tsx src/components/controls/PermissionModeSelector.test.tsx src/components/controls/ModelSelector.test.tsx src/components/shared/DirectoryPicker.test.tsx src/__tests__/pages.test.tsx

Tested: bun test src/server/__tests__/h5-access-auth.test.ts src/server/middleware/cors.test.ts

Tested: Chrome mobile viewport smoke for context, model, permission, project, branch, and worktree bottom sheets with no horizontal overflow.

Not-tested: Full bun run verify quality gate.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 11:16:34 +08:00
parent 8b5de63ae9
commit 5f9faaf01d
20 changed files with 1344 additions and 488 deletions

View File

@ -194,6 +194,24 @@ describe('Content-only pages render without errors', () => {
expect(html).not.toContain('animate-spin')
})
it('ContextUsageIndicator opens tap details in compact mobile mode', async () => {
render(
<ContextUsageIndicator
chatState="idle"
messageCount={0}
fallbackModelLabel="kimi-k2.6"
draft
compact
/>,
)
fireEvent.click(screen.getByLabelText('Context usage not calculated'))
expect(await screen.findByRole('button', { name: 'Close' })).toBeInTheDocument()
expect(screen.getAllByText('kimi-k2.6')).toHaveLength(2)
expect(screen.getAllByText('Context usage will be calculated after the session starts.')).toHaveLength(2)
})
it('EmptySession plus menu exposes uploads and slash commands before chat starts', async () => {
await act(async () => {
render(<EmptySession />)

View File

@ -725,25 +725,25 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
isHeroComposer
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
: compact
? 'border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] px-3 py-3'
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 py-3' : 'px-4 py-4'}`
? `border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] ${isMobileComposer ? 'px-0 pb-0 pt-2' : 'px-3 py-3'}`
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-0 pb-0 pt-2' : 'px-4 py-4'}`
}
>
<div
className={
isHeroComposer
? 'mx-auto flex w-full max-w-3xl flex-col'
: compact
: compact
? 'mx-auto max-w-full'
: 'mx-auto max-w-[860px]'
: `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[860px]'}`
}
>
<div
className={isHeroComposer
? 'glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4 transition-colors'
: compact
? 'glass-panel relative rounded-xl p-3 transition-colors'
: 'glass-panel relative rounded-xl p-4 transition-colors'}
? `glass-panel relative p-3 transition-colors ${isMobileComposer ? 'rounded-t-2xl rounded-b-none shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'}`
: `glass-panel relative transition-colors ${isMobileComposer ? 'rounded-t-2xl rounded-b-none p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'}`}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
@ -964,7 +964,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
: t('common.run')
: undefined
}
className={`flex items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
iconOnlyAction ? `${isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'h-8 w-8 px-0 py-0'}` : 'w-[112px] px-3 py-1.5'
} ${
!isMemberSession && isActive

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions'
import { useTranslation } from '../../i18n'
import type { ChatState } from '../../types/chat'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
type Props = {
sessionId?: string
@ -69,6 +70,7 @@ export function ContextUsageIndicator({
const [error, setError] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<number | null>(null)
const [inspectionModel, setInspectionModel] = useState<string | null>(null)
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false)
const requestSeq = useRef(0)
const contextIdentityRef = useRef('')
@ -172,7 +174,12 @@ export function ContextUsageIndicator({
<button
type="button"
aria-label={ariaLabel}
onClick={() => { void refresh() }}
onClick={() => {
if (compact) {
setMobileDetailsOpen(true)
}
void refresh()
}}
title={t('contextIndicator.title')}
data-testid="context-usage-indicator"
className={`flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-container-lowest)] ${
@ -200,7 +207,9 @@ export function ContextUsageIndicator({
</span>
</button>
<div className="pointer-events-none absolute bottom-full right-0 z-40 mb-2 w-[320px] max-w-[calc(100vw-2rem)] translate-y-1 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 text-left opacity-0 shadow-[var(--shadow-dropdown)] transition-all duration-150 group-hover/context:translate-y-0 group-hover/context:opacity-100 group-focus-within/context:translate-y-0 group-focus-within/context:opacity-100">
<div className={`pointer-events-none absolute bottom-full right-0 z-40 mb-2 w-[320px] max-w-[calc(100vw-2rem)] translate-y-1 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 text-left opacity-0 shadow-[var(--shadow-dropdown)] transition-all duration-150 group-hover/context:translate-y-0 group-hover/context:opacity-100 group-focus-within/context:translate-y-0 group-focus-within/context:opacity-100 ${
compact ? 'hidden' : ''
}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
@ -268,6 +277,81 @@ export function ContextUsageIndicator({
</div>
)}
</div>
{compact && (
<MobileBottomSheet
open={mobileDetailsOpen}
onClose={() => setMobileDetailsOpen(false)}
title={t('contextIndicator.title')}
closeLabel={t('tabs.close')}
ariaLabel={t('contextIndicator.title')}
headerExtra={(
<div className="truncate text-base font-semibold text-[var(--color-text-primary)]">
{displayModel ?? t('contextIndicator.modelUnknown')}
</div>
)}
contentClassName="p-4"
>
<div className="flex items-end justify-between gap-4">
<div className="font-mono text-4xl font-semibold text-[var(--color-text-primary)]">
{displayContext ? formatPercent(percentage) : '--'}
</div>
{contextSource === 'estimate' && (
<span className="mb-1 rounded-full border border-[var(--color-border)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{t('contextIndicator.estimate')}
</span>
)}
</div>
{displayContext ? (
<div className="mt-5">
<div className="grid grid-cols-3 gap-2 font-mono text-xs">
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.used')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(usedTokens)}</div>
</div>
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.free')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(freeTokens)}</div>
</div>
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
</div>
</div>
{details.length > 0 && (
<div className="mt-5 space-y-3">
{details.map((category) => {
const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0
return (
<div key={category.name}>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">{category.name}</span>
<span className="shrink-0 font-mono text-[var(--color-text-tertiary)]">{formatNumber(category.tokens)}</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div className="h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: category.color }} />
</div>
</div>
)
})}
</div>
)}
<div className="mt-4 text-[11px] text-[var(--color-text-tertiary)]">
{formatUpdatedAt(updatedAt, t)}
</div>
</div>
) : (
<div className="mt-5 rounded-xl bg-[var(--color-surface-container)] p-4 text-sm leading-6 text-[var(--color-text-secondary)]">
{isPendingContext
? t('contextIndicator.pendingDetail')
: loading
? t('contextIndicator.loading')
: t('contextIndicator.unavailableDetail')}
</div>
)}
</MobileBottomSheet>
)}
</div>
)
}

View File

@ -9,6 +9,9 @@ import { useSettingsStore } from '../../stores/settingsStore'
import type { SavedProvider } from '../../types/provider'
import type { RuntimeSelection } from '../../types/runtime'
import type { EffortLevel, ModelInfo } from '../../types/settings'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
type ProviderChoice = {
providerId: string | null
@ -127,6 +130,7 @@ export function ModelSelector({
compact = false,
}: Props = {}) {
const t = useTranslation()
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
const {
currentModel: storeModel,
availableModels,
@ -306,8 +310,172 @@ export function ModelSelector({
setOpen(false)
}
const dropdownContent = (
<>
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'p-3'}`} style={{ maxHeight: isMobileBrowser ? undefined : dropdownPosition?.maxHeight }}>
{!isMobileBrowser && (
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.configuration')}
</div>
)}
{isRuntimeScoped ? (
<div className="space-y-3">
{providerChoices.map((choice) => (
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
<div className="flex items-center justify-between px-2 pt-1">
<span className="truncate text-[11px] font-semibold tracking-[0.01em] text-[var(--color-text-secondary)]">
{choice.providerName}
</span>
{choice.isDefault && (
<span className="flex-shrink-0 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t('settings.providers.default')}
</span>
)}
</div>
<div className="space-y-1">
{choice.models.map((model) => {
const isSelected =
activeRuntimeSelection?.providerId === choice.providerId &&
activeRuntimeSelection.modelId === model.id
return (
<button
key={`${choice.providerId ?? 'official'}:${model.id}`}
onClick={() => handleRuntimeSelect({ providerId: choice.providerId, modelId: model.id })}
className={`
w-full rounded-lg border px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
${isSelected
? 'border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'border-transparent hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{model.name}
</div>
{model.description && (
<div className="mt-0.5 truncate pr-[6px] text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
))}
</div>
) : (
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
void setModel(model.id)
}
setOpen(false)
}}
className={`
w-full rounded-lg px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
${isSelected
? 'border border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-center gap-3">
<div className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
{!isControlled && !isRuntimeScoped && (
<div className="border-t border-[var(--color-border)] p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.effort')}
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
return (
<button
key={opt.value}
onClick={() => {
void setEffort(opt.value)
setOpen(false)
}}
className={`
rounded-lg py-2 text-center text-xs font-semibold transition-colors
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>
)}
</>
)
const dropdown = open && dropdownPosition
? createPortal(
? isMobileBrowser ? (
<MobileBottomSheet
open={open}
onClose={() => setOpen(false)}
title={t('model.configuration')}
closeLabel={t('tabs.close')}
ariaLabel={t('model.configuration')}
contentClassName="p-3"
panelRef={dropdownRef}
testId="model-selector-dropdown"
>
{dropdownContent}
</MobileBottomSheet>
) : createPortal(
<div
ref={dropdownRef}
data-testid="model-selector-dropdown"
@ -318,160 +486,19 @@ export function ModelSelector({
width: dropdownPosition.width,
}}
>
<div className="overflow-y-auto p-3" style={{ maxHeight: dropdownPosition.maxHeight }}>
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.configuration')}
</div>
{isRuntimeScoped ? (
<div className="space-y-3">
{providerChoices.map((choice) => (
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
<div className="flex items-center justify-between px-2 pt-1">
<span className="truncate text-[11px] font-semibold tracking-[0.01em] text-[var(--color-text-secondary)]">
{choice.providerName}
</span>
{choice.isDefault && (
<span className="flex-shrink-0 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t('settings.providers.default')}
</span>
)}
</div>
<div className="space-y-1">
{choice.models.map((model) => {
const isSelected =
activeRuntimeSelection?.providerId === choice.providerId &&
activeRuntimeSelection.modelId === model.id
return (
<button
key={`${choice.providerId ?? 'official'}:${model.id}`}
onClick={() => handleRuntimeSelect({ providerId: choice.providerId, modelId: model.id })}
className={`
w-full rounded-lg border px-3 py-2.5 text-left transition-colors
${isSelected
? 'border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'border-transparent hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{model.name}
</div>
{model.description && (
<div className="mt-0.5 truncate pr-[6px] text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
))}
</div>
) : (
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
void setModel(model.id)
}
setOpen(false)
}}
className={`
w-full rounded-lg px-3 py-2.5 text-left transition-colors
${isSelected
? 'border border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-center gap-3">
<div className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
{!isControlled && !isRuntimeScoped && (
<div className="border-t border-[var(--color-border)] p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.effort')}
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
return (
<button
key={opt.value}
onClick={() => {
void setEffort(opt.value)
setOpen(false)
}}
className={`
rounded-lg py-2 text-center text-xs font-semibold transition-colors
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>
)}
{dropdownContent}
</div>,
document.body,
)
: null
return (
<div ref={ref} className="relative">
<div ref={ref} className="relative min-w-0 shrink-0">
<button
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
className={`flex items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50 ${
compact ? 'max-w-[152px] px-2.5 py-1.5' : 'max-w-[280px] px-3 py-1.5'
compact ? 'max-w-[112px] px-2.5 py-1.5' : 'max-w-[280px] px-3 py-1.5'
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-2">

View File

@ -38,6 +38,7 @@ vi.mock('../../i18n', () => ({
'permMode.permPackages': 'Install packages',
'permMode.enableBypassBtn': 'Enable bypass',
'common.cancel': 'Cancel',
'tabs.close': 'Close',
}[key] ?? key),
}))
@ -68,7 +69,8 @@ describe('PermissionModeSelector mobile access', () => {
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(trigger).toHaveAttribute('aria-controls', 'permission-mode-menu')
expect(screen.getByRole('menu')).toHaveClass('inset-x-4', 'bottom-4')
expect(screen.getByRole('dialog', { name: 'Execution Permissions' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /Auto accept edits/ })).toBeInTheDocument()
})
})

View File

@ -9,6 +9,7 @@ import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
const MODE_ICONS: Record<PermissionMode, string> = {
default: 'verified_user',
@ -118,11 +119,8 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
}
}, [open])
const menuContent = (
<>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('permMode.executionPermissions')}
</div>
const permissionOptions = (
<div id={menuId} ref={menuRef} role="menu">
{PERMISSION_ITEMS.map((item) => (
<button
key={item.value}
@ -142,25 +140,34 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
setOpen(false)
}}
className={`
w-full flex items-start gap-3 px-4 py-3 text-left transition-colors
flex w-full items-start gap-3 px-4 py-3 text-left transition-colors
hover:bg-[var(--color-surface-hover)]
${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''}
`}
>
<span className={`material-symbols-outlined text-[20px] mt-0.5 ${item.color || 'text-[var(--color-text-secondary)]'}`}>
<span className={`material-symbols-outlined mt-0.5 text-[20px] ${item.color || 'text-[var(--color-text-secondary)]'}`}>
{item.icon}
</span>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{item.description}</div>
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">{item.description}</div>
</div>
{item.value === currentMode && (
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)] mt-0.5" style={{ fontVariationSettings: "'FILL' 1" }}>
<span className="material-symbols-outlined mt-0.5 text-[16px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
))}
</div>
)
const menuContent = (
<>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('permMode.executionPermissions')}
</div>
{permissionOptions}
</>
)
@ -187,19 +194,17 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
</button>
{open && (
isMobile ? createPortal(
<div className="fixed inset-0 z-50 bg-black/20" onClick={() => setOpen(false)}>
<div
id={menuId}
ref={menuRef}
role="menu"
className="absolute inset-x-4 bottom-4 max-h-[min(70vh,520px)] overflow-y-auto rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
onClick={(event) => event.stopPropagation()}
>
{menuContent}
</div>
</div>,
document.body,
isMobile ? (
<MobileBottomSheet
open={open}
onClose={() => setOpen(false)}
title={t('permMode.executionPermissions')}
closeLabel={t('tabs.close')}
ariaLabel={t('permMode.executionPermissions')}
contentClassName="py-2"
>
{permissionOptions}
</MobileBottomSheet>
) : (
<div id={menuId} ref={menuRef} role="menu" className="absolute left-0 bottom-full mb-2 w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2">
{menuContent}

View File

@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
fetchAll: vi.fn(),
restoreTabs: vi.fn(),
connectToSession: vi.fn(),
setActiveTab: vi.fn(),
tabState: {
activeTabId: null as string | null,
tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>,
@ -32,17 +33,31 @@ vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => mocks.isMobile,
}))
vi.mock('../../stores/tabStore', () => ({
SETTINGS_TAB_ID: '__settings__',
useTabStore: {
getState: () => ({
restoreTabs: mocks.restoreTabs,
activeTabId: mocks.tabState.activeTabId,
tabs: mocks.tabState.tabs,
openTab: vi.fn(),
}),
},
}))
vi.mock('../../stores/tabStore', () => {
const useTabStore = (selector: (state: {
tabs: typeof mocks.tabState.tabs
activeTabId: string | null
setActiveTab: typeof mocks.setActiveTab
}) => unknown) => selector({
tabs: mocks.tabState.tabs,
activeTabId: mocks.tabState.activeTabId,
setActiveTab: mocks.setActiveTab,
})
useTabStore.getState = () => ({
restoreTabs: mocks.restoreTabs,
activeTabId: mocks.tabState.activeTabId,
tabs: mocks.tabState.tabs,
openTab: vi.fn(),
setActiveTab: mocks.setActiveTab,
})
useTabStore.setState = (next: { activeTabId?: string | null }) => {
if ('activeTabId' in next) mocks.tabState.activeTabId = next.activeTabId ?? null
}
return {
SETTINGS_TAB_ID: '__settings__',
useTabStore,
}
})
vi.mock('../../stores/chatStore', () => ({
useChatStore: {
@ -100,6 +115,9 @@ describe('AppShell boot flow', () => {
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
mocks.fetchAll.mockResolvedValue(undefined)
mocks.restoreTabs.mockResolvedValue(undefined)
mocks.setActiveTab.mockImplementation((sessionId: string) => {
mocks.tabState.activeTabId = sessionId
})
mocks.tabState.activeTabId = null
mocks.tabState.tabs = []
useUIStore.setState({ sidebarOpen: true })
@ -252,4 +270,21 @@ describe('AppShell boot flow', () => {
expect(useUIStore.getState().sidebarOpen).toBe(false)
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed')
})
it('keeps browser H5 mobile on chat tabs when settings was restored as active', async () => {
mocks.isMobile = true
mocks.tabState.activeTabId = '__settings__'
mocks.tabState.tabs = [
{ sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' },
{ sessionId: 'session-1', title: 'Existing session', type: 'session', status: 'idle' },
]
render(<AppShell />)
await screen.findByText('content loaded')
expect(screen.queryByText('tabs loaded')).not.toBeInTheDocument()
await waitFor(() => {
expect(mocks.setActiveTab).toHaveBeenCalledWith('session-1')
})
})
})

View File

@ -19,6 +19,11 @@ import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import { H5ConnectionView } from './H5ConnectionView'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import type { Tab } from '../../stores/tabStore'
function isChatTab(tab: Tab | undefined) {
return tab?.type === 'session'
}
export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)
@ -33,6 +38,9 @@ export function AppShell() {
const t = useTranslation()
const tauriRuntime = isTauriRuntime()
const isMobileShell = useMobileViewport() && !tauriRuntime
const tabs = useTabStore((s) => s.tabs)
const activeTabId = useTabStore((s) => s.activeTabId)
const setActiveTab = useTabStore((s) => s.setActiveTab)
const wasMobileShellRef = useRef(false)
const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen
const sidebarHiddenProps: HTMLAttributes<HTMLDivElement> & { inert?: '' } =
@ -120,6 +128,18 @@ export function AppShell() {
wasMobileShellRef.current = isMobileShell
}, [isMobileShell, setSidebarOpen])
useEffect(() => {
if (!ready || !isMobileShell) return
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
if (isChatTab(activeTab) || (!activeTab && !activeTabId)) return
const nextChatTab = tabs.find(isChatTab)
if (nextChatTab) {
setActiveTab(nextChatTab.sessionId)
return
}
useTabStore.setState({ activeTabId: null })
}, [activeTabId, isMobileShell, ready, setActiveTab, tabs])
const setEffectiveSidebarOpen = (open: boolean) => {
if (isMobileShell) {
setMobileSidebarOpen(open)
@ -204,7 +224,7 @@ export function AppShell() {
</button>
</div>
) : null}
<TabBar />
{!isMobileShell ? <TabBar /> : null}
<ContentRouter />
</main>
<ToastContainer />

View File

@ -204,7 +204,7 @@ describe('Sidebar', () => {
expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col')
})
it('closes the mobile drawer after navigation actions', async () => {
it('keeps mobile navigation focused on chat sessions', async () => {
const onRequestClose = vi.fn()
createSession.mockResolvedValue('session-mobile-new')
useSessionStore.setState({
@ -224,11 +224,11 @@ describe('Sidebar', () => {
render(<Sidebar isMobile onRequestClose={onRequestClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }))
expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
expect(onRequestClose).toHaveBeenCalledTimes(2)
expect(onRequestClose).toHaveBeenCalledTimes(1)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
@ -237,7 +237,7 @@ describe('Sidebar', () => {
await waitFor(() => {
expect(createSession).toHaveBeenCalled()
})
expect(onRequestClose).toHaveBeenCalledTimes(3)
expect(onRequestClose).toHaveBeenCalledTimes(2)
})
it('shows a loading state instead of an empty session list while initial fetch is pending', () => {

View File

@ -218,19 +218,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
>
{t('sidebar.newSession')}
</NavItem>
<NavItem
active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!expanded}
label={t('sidebar.scheduled')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
closeMobileDrawer()
}}
icon={<ClockIcon />}
>
{t('sidebar.scheduled')}
</NavItem>
{!isMobile && (
<NavItem
active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!expanded}
label={t('sidebar.scheduled')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
closeMobileDrawer()
}}
icon={<ClockIcon />}
>
{t('sidebar.scheduled')}
</NavItem>
)}
</div>
{expanded ? (
@ -359,21 +361,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<div className="flex-1" aria-hidden="true" />
)}
<div className={`border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}>
<NavItem
active={activeTabId === SETTINGS_TAB_ID}
collapsed={!expanded}
label={t('sidebar.settings')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
closeMobileDrawer()
}}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
{t('sidebar.settings')}
</NavItem>
</div>
{!isMobile && (
<div className={`border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}>
<NavItem
active={activeTabId === SETTINGS_TAB_ID}
collapsed={!expanded}
label={t('sidebar.settings')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
closeMobileDrawer()
}}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
{t('sidebar.settings')}
</NavItem>
</div>
)}
{contextMenu && sidebarOpen && (
<div

View File

@ -3,6 +3,8 @@ import { createPortal } from 'react-dom'
import { sessionsApi, type RecentProject } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
import { useTranslation } from '../../i18n'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { MobileBottomSheet } from './MobileBottomSheet'
type Props = {
value: string
@ -42,6 +44,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
const dropdownRef = useRef<HTMLDivElement>(null)
@ -156,8 +159,141 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
? 'flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
: 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors'
const dropdownClassName = 'w-[400px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
const dropdownStyle = {
position: 'fixed' as const,
left: dropdownPos?.left,
...(dropdownPos?.direction === 'down'
? { top: dropdownPos.top }
: { bottom: window.innerHeight - (dropdownPos?.top ?? 0) }),
zIndex: 9999,
}
const dropdownTitle = mode === 'recent' ? t('dirPicker.recent') : t('dirPicker.chooseProjectFolder')
const dropdownContent = mode === 'recent' ? (
<>
{!isMobileBrowser && (
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('dirPicker.recent')}
</div>
)}
<div className={`${isMobileBrowser ? '' : 'max-h-[300px]'} overflow-y-auto`}>
{loading ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : projects.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noRecent')}</div>
) : (
projects.map((project) => {
const isSelected = project.realPath === value
return (
<button
key={project.projectPath}
onClick={() => handleSelect(project.realPath)}
className={`flex w-full items-center gap-3 px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
isMobileBrowser ? 'min-h-[72px] py-3.5' : 'py-3'
} ${
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
}`}
>
{project.isGit ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
</svg>
) : (
<span className="material-symbols-outlined flex-shrink-0 text-[20px] text-[var(--color-text-secondary)]">folder</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{project.repoName || project.projectName}
</div>
<div className="truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{project.realPath}
</div>
</div>
{isSelected && (
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
)}
</button>
)
})
)}
</div>
<div className="border-t border-[var(--color-border)]">
<button
onClick={handleChooseFolder}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">create_new_folder</span>
<span className="text-sm text-[var(--color-text-secondary)]">{t('dirPicker.chooseFolder')}</span>
</button>
</div>
</>
) : (
<>
<div className="flex flex-wrap items-center gap-1 border-b border-[var(--color-border)] px-3 py-2">
<button onClick={() => setMode('recent')} className="mr-2 text-xs text-[var(--color-text-accent)] hover:underline">
{'← ' + t('dirPicker.recent')}
</button>
<button onClick={() => loadBrowseDir('/')} className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]">/</button>
{browsePath.split('/').filter(Boolean).map((seg, i, arr) => (
<span key={i} className="flex items-center gap-1">
<span className="text-[10px] text-[var(--color-text-tertiary)]">/</span>
<button
onClick={() => loadBrowseDir('/' + arr.slice(0, i + 1).join('/'))}
className="text-[10px] text-[var(--color-text-accent)] hover:underline"
>{seg}</button>
</span>
))}
</div>
<div className={`${isMobileBrowser ? '' : 'max-h-[240px]'} overflow-y-auto`}>
{loading ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : (
<>
{browseParent && browseParent !== browsePath && (
<button onClick={() => loadBrowseDir(browseParent)} className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">arrow_upward</span>
<span className="text-xs text-[var(--color-text-secondary)]">..</span>
</button>
)}
{browseEntries.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noSubdirs')}</div>
) : browseEntries.map((entry) => (
<div
key={entry.path}
className="flex w-full items-center gap-2 px-3 py-2 hover:bg-[var(--color-surface-hover)]"
>
<button
type="button"
onClick={() => loadBrowseDir(entry.path)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">folder</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-primary)]">{entry.name}</span>
</button>
<button type="button" onClick={() => handleSelect(entry.path)} className="rounded px-2 py-0.5 text-[10px] font-semibold text-[var(--color-brand)] transition-colors hover:bg-[var(--color-primary-fixed)]">
{t('common.select')}
</button>
</div>
))}
</>
)}
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-3 py-2">
<span className="truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-tertiary)]">{browsePath}</span>
<button onClick={() => handleSelect(browsePath)} className="rounded-lg bg-[var(--color-brand)] px-3 py-1.5 text-xs font-semibold text-white hover:opacity-90">
{t('dirPicker.useThisFolder')}
</button>
</div>
</>
)
return (
<div ref={ref} className={isWorkbar ? 'relative min-w-0 max-w-[320px] shrink' : 'relative'}>
<div ref={ref} className={isWorkbar ? `relative min-w-0 ${isMobileBrowser ? 'flex-1' : 'max-w-[320px] shrink'}` : 'relative'}>
{/* Trigger — shows selected project chip or placeholder */}
{value ? (
<button
@ -190,144 +326,27 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
</button>
)}
{/* Dropdown — rendered via portal to escape overflow clipping */}
{isOpen && dropdownPos && createPortal(
<div
ref={dropdownRef}
className="w-[400px] bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] rounded-xl shadow-[var(--shadow-dropdown)] overflow-hidden"
style={{
position: 'fixed',
left: dropdownPos.left,
...(dropdownPos.direction === 'down'
? { top: dropdownPos.top }
: { bottom: window.innerHeight - dropdownPos.top }),
zIndex: 9999,
}}
>
{mode === 'recent' ? (
<>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('dirPicker.recent')}
</div>
<div className="max-h-[300px] overflow-y-auto">
{loading ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : projects.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noRecent')}</div>
) : (
projects.map((project) => {
const isSelected = project.realPath === value
return (
<button
key={project.projectPath}
onClick={() => handleSelect(project.realPath)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
}`}
>
{project.isGit ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
</svg>
) : (
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)] flex-shrink-0">folder</span>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)] truncate">
{project.repoName || project.projectName}
</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] truncate font-[var(--font-mono)]">
{project.realPath}
</div>
</div>
{isSelected && (
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)] flex-shrink-0" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
)}
</button>
)
})
)}
</div>
{/* Divider + Choose different folder */}
<div className="border-t border-[var(--color-border)]">
<button
onClick={handleChooseFolder}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">create_new_folder</span>
<span className="text-sm text-[var(--color-text-secondary)]">{t('dirPicker.chooseFolder')}</span>
</button>
</div>
</>
) : (
/* Directory tree browser (web only) */
<>
<div className="px-3 py-2 border-b border-[var(--color-border)] flex items-center gap-1 flex-wrap">
<button onClick={() => setMode('recent')} className="text-xs text-[var(--color-text-accent)] hover:underline mr-2">
{'← ' + t('dirPicker.recent')}
</button>
<button onClick={() => loadBrowseDir('/')} className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]">/</button>
{browsePath.split('/').filter(Boolean).map((seg, i, arr) => (
<span key={i} className="flex items-center gap-1">
<span className="text-[10px] text-[var(--color-text-tertiary)]">/</span>
<button
onClick={() => loadBrowseDir('/' + arr.slice(0, i + 1).join('/'))}
className="text-[10px] text-[var(--color-text-accent)] hover:underline"
>{seg}</button>
</span>
))}
</div>
<div className="max-h-[240px] overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : (
<>
{browseParent && browseParent !== browsePath && (
<button onClick={() => loadBrowseDir(browseParent)} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">arrow_upward</span>
<span className="text-xs text-[var(--color-text-secondary)]">..</span>
</button>
)}
{browseEntries.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noSubdirs')}</div>
) : browseEntries.map((entry) => (
<div
key={entry.path}
className="flex w-full items-center gap-2 px-3 py-2 hover:bg-[var(--color-surface-hover)]"
>
<button
type="button"
onClick={() => loadBrowseDir(entry.path)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">folder</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-primary)]">{entry.name}</span>
</button>
<button type="button" onClick={() => handleSelect(entry.path)} className="rounded px-2 py-0.5 text-[10px] font-semibold text-[var(--color-brand)] transition-colors hover:bg-[var(--color-primary-fixed)]">
{t('common.select')}
</button>
</div>
))}
</>
)}
</div>
{/* Use current folder */}
<div className="px-3 py-2 border-t border-[var(--color-border)] flex justify-between items-center">
<span className="text-[10px] text-[var(--color-text-tertiary)] font-[var(--font-mono)] truncate">{browsePath}</span>
<button onClick={() => handleSelect(browsePath)} className="px-3 py-1.5 bg-[var(--color-brand)] text-white text-xs font-semibold rounded-lg hover:opacity-90">
{t('dirPicker.useThisFolder')}
</button>
</div>
</>
)}
</div>,
document.body
{isOpen && dropdownPos && (
isMobileBrowser ? (
<MobileBottomSheet
open={isOpen}
onClose={() => setIsOpen(false)}
title={dropdownTitle}
closeLabel={t('tabs.close')}
panelRef={dropdownRef}
>
{dropdownContent}
</MobileBottomSheet>
) : createPortal(
<div
ref={dropdownRef}
className={dropdownClassName}
style={dropdownStyle}
>
{dropdownContent}
</div>,
document.body,
)
)}
</div>
)

View File

@ -0,0 +1,94 @@
import { useEffect, type ReactNode, type Ref } from 'react'
import { createPortal } from 'react-dom'
type Props = {
open: boolean
onClose: () => void
title: ReactNode
children: ReactNode
closeLabel?: string
headerExtra?: ReactNode
footer?: ReactNode
id?: string
role?: string
ariaLabel?: string
contentClassName?: string
panelClassName?: string
panelRef?: Ref<HTMLDivElement>
testId?: string
}
export function MobileBottomSheet({
open,
onClose,
title,
children,
closeLabel = 'Close',
headerExtra,
footer,
id,
role = 'dialog',
ariaLabel,
contentClassName = '',
panelClassName = '',
panelRef,
testId,
}: Props) {
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-[10000] bg-black/25" onClick={onClose}>
<div
ref={panelRef}
id={id}
role={role}
aria-modal={role === 'dialog' ? true : undefined}
aria-label={ariaLabel ?? (typeof title === 'string' ? title : undefined)}
data-testid={testId}
className={`absolute inset-x-0 bottom-0 flex max-h-[min(78dvh,640px)] min-h-0 flex-col overflow-hidden rounded-t-2xl border-x-0 border-y border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_-18px_48px_rgba(54,35,28,0.22)] ${panelClassName}`}
onClick={(event) => event.stopPropagation()}
>
<div className="shrink-0 border-b border-[var(--color-border)] px-4 py-3">
<div className="flex min-h-10 items-center justify-between gap-3">
<div className="min-w-0 text-[11px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{title}
</div>
<button
type="button"
aria-label={closeLabel}
onClick={onClose}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
{headerExtra && (
<div className="mt-3">
{headerExtra}
</div>
)}
</div>
<div className={`min-h-0 flex-1 overflow-y-auto ${contentClassName}`}>
{children}
</div>
{footer && (
<div className="shrink-0 border-t border-[var(--color-border)]">
{footer}
</div>
)}
</div>
</div>,
document.body,
)
}

View File

@ -0,0 +1,166 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const viewportMocks = vi.hoisted(() => ({
isMobile: false,
isTauri: false,
}))
const apiMocks = vi.hoisted(() => ({
getRepositoryContext: vi.fn(),
}))
vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => viewportMocks.isMobile,
}))
vi.mock('../../lib/desktopRuntime', () => ({
isTauriRuntime: () => viewportMocks.isTauri,
}))
vi.mock('../../api/sessions', () => ({
sessionsApi: {
getRepositoryContext: apiMocks.getRepositoryContext,
},
}))
vi.mock('./DirectoryPicker', () => ({
DirectoryPicker: ({ value }: { value: string }) => (
<button type="button">Project {value}</button>
),
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => ({
'common.loading': 'Loading',
'repoLaunch.checkedOut': 'Checked out',
'repoLaunch.checkedOutWarning': 'Branch is checked out elsewhere',
'repoLaunch.currentBranch': 'Current branch',
'repoLaunch.dirtyWarning': 'Dirty worktree',
'repoLaunch.localBranch': 'Local branch',
'repoLaunch.missingWorkdir': 'Missing working directory',
'repoLaunch.noBranch': 'No branch',
'repoLaunch.noBranchMatch': 'No matching branches',
'repoLaunch.remoteBranch': 'Remote branch',
'repoLaunch.searchBranch': 'Search branches',
'repoLaunch.selectBranch': 'Select branch',
'repoLaunch.selectWorktree': 'Select worktree mode',
'repoLaunch.worktreeCurrent': 'Current worktree',
'repoLaunch.worktreeIsolated': 'Isolated worktree',
'tabs.close': 'Close',
}[key] ?? key),
}))
import { RepositoryLaunchControls } from './RepositoryLaunchControls'
const okRepositoryContext = {
state: 'ok' as const,
root: '/repo',
currentBranch: 'main',
defaultBranch: 'main',
dirty: false,
branches: [
{
name: 'main',
current: true,
local: true,
remote: false,
checkedOut: false,
remoteRef: null,
worktreePath: null,
},
{
name: 'feature/h5',
current: false,
local: true,
remote: false,
checkedOut: false,
remoteRef: null,
worktreePath: null,
},
],
}
function renderControls(props: Partial<ComponentProps<typeof RepositoryLaunchControls>> = {}) {
const defaultProps: ComponentProps<typeof RepositoryLaunchControls> = {
workDir: '/repo',
onWorkDirChange: vi.fn(),
branch: 'main',
onBranchChange: vi.fn(),
useWorktree: false,
onUseWorktreeChange: vi.fn(),
}
return render(<RepositoryLaunchControls {...defaultProps} {...props} />)
}
async function openBranchMenu() {
const trigger = await screen.findByRole('button', { name: 'Select branch: main' })
fireEvent.click(trigger)
return trigger
}
describe('RepositoryLaunchControls', () => {
beforeEach(() => {
viewportMocks.isMobile = false
viewportMocks.isTauri = false
apiMocks.getRepositoryContext.mockReset()
apiMocks.getRepositoryContext.mockResolvedValue(okRepositoryContext)
Element.prototype.scrollIntoView = vi.fn()
})
it('keeps the desktop branch dropdown when not in mobile browser mode', async () => {
renderControls()
await openBranchMenu()
const listbox = await screen.findByRole('listbox', { name: 'Select branch' })
expect(listbox).toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: 'Select branch' })).not.toBeInTheDocument()
expect(listbox.parentElement?.className).toContain('w-[390px]')
})
it('uses the full-width mobile bottom sheet in H5 mobile browser mode', async () => {
viewportMocks.isMobile = true
viewportMocks.isTauri = false
renderControls()
await openBranchMenu()
const dialog = await screen.findByRole('dialog', { name: 'Select branch' })
expect(dialog).toHaveClass('inset-x-0')
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
expect(screen.getByRole('listbox', { name: 'Select branch' })).toBeInTheDocument()
})
it('does not use the H5 mobile sheet inside Tauri even on a narrow viewport', async () => {
viewportMocks.isMobile = true
viewportMocks.isTauri = true
renderControls()
await openBranchMenu()
const listbox = await screen.findByRole('listbox', { name: 'Select branch' })
expect(listbox).toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: 'Select branch' })).not.toBeInTheDocument()
})
it('keeps keyboard branch selection working from the search field', async () => {
const onBranchChange = vi.fn()
renderControls({ onBranchChange })
await openBranchMenu()
const input = await screen.findByPlaceholderText('Search branches')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => {
expect(onBranchChange).toHaveBeenCalledWith('feature/h5')
})
})
})

View File

@ -16,6 +16,9 @@ import {
} from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { DirectoryPicker } from './DirectoryPicker'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { MobileBottomSheet } from './MobileBottomSheet'
type Props = {
workDir: string
@ -54,6 +57,7 @@ export function RepositoryLaunchControls({
disabled = false,
}: Props) {
const t = useTranslation()
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
const [context, setContext] = useState<RepositoryContextResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -287,12 +291,55 @@ export function RepositoryLaunchControls({
const worktreeLabel = useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')
const workbarButtonClassName = 'group inline-flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50'
const branchMenuClassName = isMobileBrowser
? 'max-h-[72dvh] overflow-hidden rounded-t-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_-18px_48px_rgba(54,35,28,0.2)]'
: 'w-[390px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
const branchMenuStyle = isMobileBrowser
? {
position: 'fixed' as const,
left: 12,
right: 12,
bottom: 'calc(env(safe-area-inset-bottom) + 84px)',
zIndex: 9999,
}
: {
position: 'fixed' as const,
left: menuPos?.left,
...(menuPos?.direction === 'down'
? { top: menuPos.top }
: { bottom: window.innerHeight - (menuPos?.top ?? 0) }),
zIndex: 9999,
}
const worktreeMenuClassName = isMobileBrowser
? 'overflow-hidden rounded-t-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[0_-18px_48px_rgba(54,35,28,0.2)]'
: 'w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]'
const worktreeMenuStyle = isMobileBrowser
? {
position: 'fixed' as const,
left: 12,
right: 12,
bottom: 'calc(env(safe-area-inset-bottom) + 84px)',
zIndex: 9999,
}
: {
position: 'fixed' as const,
left: worktreeMenuPos?.left,
...(worktreeMenuPos?.direction === 'down'
? { top: worktreeMenuPos.top }
: { bottom: window.innerHeight - (worktreeMenuPos?.top ?? 0) }),
zIndex: 9999,
}
return (
<div ref={rootRef} className="flex min-w-0 flex-col gap-2">
<div className="flex min-h-[48px] min-w-0 flex-nowrap items-center justify-start gap-x-1.5 gap-y-1 overflow-hidden rounded-b-xl border-t border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] px-4 py-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<div ref={rootRef} className={`flex min-w-0 flex-col ${isMobileBrowser ? 'gap-0' : 'gap-2'}`}>
<div className={`flex min-w-0 items-center justify-start gap-x-1.5 gap-y-1 overflow-hidden border-t border-[var(--color-border-separator)] ${
isMobileBrowser
? 'min-h-[52px] flex-wrap rounded-none bg-[var(--color-surface-container-lowest)] px-3 py-2 shadow-none'
: 'min-h-[48px] flex-nowrap rounded-b-xl bg-[var(--color-surface-container-low)] px-4 py-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]'
}`}>
<DirectoryPicker value={workDir} onChange={onWorkDirChange} variant="workbar" isGitProject={isGitReady} />
{loading && workDir && (
{loading && workDir && !isMobileBrowser && (
<div className="inline-flex h-9 items-center gap-1.5 rounded-[7px] px-2.5 text-[13px] text-[var(--color-text-secondary)]">
<Loader2 size={14} className="shrink-0 animate-spin" />
<span>{t('common.loading')}</span>
@ -315,7 +362,7 @@ export function RepositoryLaunchControls({
setWorktreeMenuOpen(false)
setBranchFilter('')
}}
className={`${workbarButtonClassName} max-w-[260px] shrink`}
className={`${workbarButtonClassName} ${isMobileBrowser ? 'max-w-[160px] shrink-0 bg-[var(--color-surface-container)]' : 'max-w-[260px] shrink'}`}
>
<GitBranch size={17} className="shrink-0 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
@ -337,7 +384,7 @@ export function RepositoryLaunchControls({
setWorktreeMenuOpen((prev) => !prev)
setBranchMenuOpen(false)
}}
className={`${workbarButtonClassName} shrink-0 ${
className={`${workbarButtonClassName} shrink-0 ${isMobileBrowser ? 'bg-[var(--color-surface-container)]' : ''} ${
useWorktree
? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
: ''
@ -371,98 +418,201 @@ export function RepositoryLaunchControls({
</div>
)}
{branchMenuOpen && menuPos && createPortal(
<div
ref={menuRef}
className="w-[390px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
style={{
position: 'fixed',
left: menuPos.left,
...(menuPos.direction === 'down'
? { top: menuPos.top }
: { bottom: window.innerHeight - menuPos.top }),
zIndex: 9999,
}}
>
<div className="border-b border-[var(--color-border)] p-3">
<label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('repoLaunch.selectBranch')}
</label>
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
ref={searchRef}
value={branchFilter}
onChange={(event) => setBranchFilter(event.target.value)}
onKeyDown={handleBranchKeyDown}
aria-controls={listboxId}
aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined}
placeholder={t('repoLaunch.searchBranch')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
</div>
<div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="max-h-[280px] overflow-y-auto py-1">
{filteredBranches.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
{t('repoLaunch.noBranchMatch')}
{branchMenuOpen && menuPos && (
isMobileBrowser ? (
<MobileBottomSheet
open={branchMenuOpen}
onClose={() => setBranchMenuOpen(false)}
title={t('repoLaunch.selectBranch')}
closeLabel={t('tabs.close')}
panelRef={menuRef}
headerExtra={(
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
ref={searchRef}
value={branchFilter}
onChange={(event) => setBranchFilter(event.target.value)}
onKeyDown={handleBranchKeyDown}
aria-controls={listboxId}
aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined}
placeholder={t('repoLaunch.searchBranch')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
) : filteredBranches.map((candidate, index) => {
const isSelected = candidate.name === selectedBranch?.name
return (
<button
key={candidate.name}
id={`${listboxId}-option-${index}`}
ref={(el) => { itemRefs.current[index] = el }}
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectBranch(candidate)}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} />
<GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]">
{candidate.name}
)}
>
<div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="py-1">
{filteredBranches.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
{t('repoLaunch.noBranchMatch')}
</div>
) : filteredBranches.map((candidate, index) => {
const isSelected = candidate.name === selectedBranch?.name
return (
<button
key={candidate.name}
id={`${listboxId}-option-${index}`}
ref={(el) => { itemRefs.current[index] = el }}
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectBranch(candidate)}
className={`flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} />
<GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]">
{candidate.name}
</span>
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
{candidate.current
? t('repoLaunch.currentBranch')
: candidate.checkedOut
? t('repoLaunch.checkedOut')
: candidate.remote && !candidate.local
? candidate.remoteRef || t('repoLaunch.remoteBranch')
: t('repoLaunch.localBranch')}
</span>
</span>
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
{candidate.current
? t('repoLaunch.currentBranch')
: candidate.checkedOut
? t('repoLaunch.checkedOut')
: candidate.remote && !candidate.local
? candidate.remoteRef || t('repoLaunch.remoteBranch')
: t('repoLaunch.localBranch')}
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
</button>
)
})}
</div>
</MobileBottomSheet>
) : createPortal(
<div
ref={menuRef}
className={branchMenuClassName}
style={branchMenuStyle}
>
<div className="border-b border-[var(--color-border)] p-3">
<label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('repoLaunch.selectBranch')}
</label>
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
ref={searchRef}
value={branchFilter}
onChange={(event) => setBranchFilter(event.target.value)}
onKeyDown={handleBranchKeyDown}
aria-controls={listboxId}
aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined}
placeholder={t('repoLaunch.searchBranch')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
</div>
<div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="max-h-[280px] overflow-y-auto py-1">
{filteredBranches.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
{t('repoLaunch.noBranchMatch')}
</div>
) : filteredBranches.map((candidate, index) => {
const isSelected = candidate.name === selectedBranch?.name
return (
<button
key={candidate.name}
id={`${listboxId}-option-${index}`}
ref={(el) => { itemRefs.current[index] = el }}
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectBranch(candidate)}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} />
<GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]">
{candidate.name}
</span>
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
{candidate.current
? t('repoLaunch.currentBranch')
: candidate.checkedOut
? t('repoLaunch.checkedOut')
: candidate.remote && !candidate.local
? candidate.remoteRef || t('repoLaunch.remoteBranch')
: t('repoLaunch.localBranch')}
</span>
</span>
</span>
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
</button>
)
})}
</div>
</div>,
document.body,
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
</button>
)
})}
</div>
</div>,
document.body,
)
)}
{worktreeMenuOpen && worktreeMenuPos && createPortal(
<div
ref={worktreeMenuRef}
className="w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
style={{
position: 'fixed',
left: worktreeMenuPos.left,
...(worktreeMenuPos.direction === 'down'
? { top: worktreeMenuPos.top }
: { bottom: window.innerHeight - worktreeMenuPos.top }),
zIndex: 9999,
}}
>
{worktreeMenuOpen && worktreeMenuPos && (
isMobileBrowser ? (
<MobileBottomSheet
open={worktreeMenuOpen}
onClose={() => setWorktreeMenuOpen(false)}
title={t('repoLaunch.selectWorktree')}
closeLabel={t('tabs.close')}
panelRef={worktreeMenuRef}
contentClassName="py-2"
>
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
<button
type="button"
role="option"
aria-selected={!useWorktree}
onClick={() => selectWorktreeMode(false)}
className={`flex min-h-[52px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${
!useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeCurrent')}
</span>
</span>
{!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
<button
type="button"
role="option"
aria-selected={useWorktree}
onClick={() => selectWorktreeMode(true)}
className={`flex min-h-[52px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeIsolated')}
</span>
</span>
{useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
</div>
</MobileBottomSheet>
) : createPortal(
<div
ref={worktreeMenuRef}
className={worktreeMenuClassName}
style={worktreeMenuStyle}
>
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
<button
type="button"
@ -502,6 +652,7 @@ export function RepositoryLaunchControls({
</div>
</div>,
document.body,
)
)}
</div>
)

View File

@ -23,6 +23,7 @@ import {
initializeDesktopServerUrl,
isLoopbackHostname,
requiresH5AuthForServerUrl,
saveAndVerifyH5Connection,
} from './desktopRuntime'
describe('desktopRuntime browser H5 bootstrap', () => {
@ -48,11 +49,18 @@ describe('desktopRuntime browser H5 bootstrap', () => {
expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true)
expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(true)
})
it('only lets localhost browser WebUI bypass H5 auth for private LAN servers', () => {
expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(false)
expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(false)
expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(false)
expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(true)
expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(true)
})
it('clears an invalid token but preserves the remembered remote server URL', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'stale-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
@ -60,7 +68,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
Object.assign(new Error('Invalid or missing H5 access token'), { status: 401 }),
)
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
await expect(saveAndVerifyH5Connection('https://public.example.com/app', 'stale-token')).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com/app',
message: 'The saved H5 token is no longer valid.',
@ -88,10 +96,9 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => {
vi.useFakeTimers()
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Funreachable.example.com')
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch
const startup = expect(initializeDesktopServerUrl()).rejects.toMatchObject({
const startup = expect(saveAndVerifyH5Connection('https://unreachable.example.com', 'h5_token')).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://unreachable.example.com',
message: 'Unable to reach https://unreachable.example.com. Check the server URL or network access.',
@ -106,14 +113,12 @@ describe('desktopRuntime browser H5 bootstrap', () => {
})
it('normalizes remote verify failures like disabled H5 or CORS into recoverable H5 errors', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'h5_token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to fetch'))
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
await expect(saveAndVerifyH5Connection('https://public.example.com', 'h5_token')).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com',
message: 'Unable to verify the H5 access token.',
@ -124,4 +129,34 @@ describe('desktopRuntime browser H5 bootstrap', () => {
)
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
})
it('lets localhost browser WebUI connect to a LAN-bound server without H5 token', async () => {
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe('http://192.168.0.102:28670')
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://192.168.0.102:28670')
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
expect(clientMocks.postVerify).not.toHaveBeenCalled()
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBeNull()
expect(globalThis.fetch).toHaveBeenCalledWith('http://192.168.0.102:28670/api/status', {
cache: 'no-store',
})
})
it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => {
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670')
globalThis.fetch = vi.fn()
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 401 })) as typeof fetch
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'http://192.168.0.102:28670',
reason: 'missing-token',
} satisfies Partial<H5ConnectionRequiredError>)
})
})

View File

@ -151,6 +151,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
}
if (!browserH5Runtime) {
await ensureBrowserApiAccessibleWithoutH5(requestedUrl)
return requestedUrl
}
@ -203,6 +204,19 @@ async function verifyH5Access() {
await api.post<{ ok: true }>('/api/h5-access/verify')
}
async function ensureBrowserApiAccessibleWithoutH5(serverUrl: string) {
const response = await fetch(`${serverUrl}/api/status`, {
cache: 'no-store',
})
if (response.status === 401) {
throw new H5ConnectionRequiredError(
'Enter your H5 token to continue.',
serverUrl,
'missing-token',
)
}
}
function normalizeServerUrl(value: string | null | undefined) {
const trimmed = value?.trim()
if (!trimmed) return null
@ -224,14 +238,54 @@ export function isLoopbackHostname(hostname: string) {
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
}
export function requiresH5AuthForServerUrl(serverUrl: string) {
export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) {
try {
return !isLoopbackHostname(new URL(serverUrl).hostname)
const serverHostname = new URL(serverUrl).hostname
if (isLoopbackHostname(serverHostname)) {
return false
}
if (browserHostname && isLoopbackHostname(browserHostname) && isPrivateNetworkHostname(serverHostname)) {
return false
}
return true
} catch {
return false
}
}
function isPrivateNetworkHostname(hostname: string) {
const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
if (normalized === '0.0.0.0') {
return true
}
const ipv4Parts = normalized.split('.')
if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d+$/.test(part))) {
const octets = ipv4Parts.map((part) => Number(part))
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return false
}
const a = octets[0] ?? -1
const b = octets[1] ?? -1
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
)
}
return normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80:')
}
function getBrowserHostname() {
if (typeof window === 'undefined') return null
return window.location.hostname
}
function normalizeBrowserH5Error(error: unknown, serverUrl: string) {
if (error instanceof H5ConnectionRequiredError) {
return error

View File

@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
wsOnMessage: vi.fn(),
wsSend: vi.fn(),
wsDisconnect: vi.fn(),
isMobile: false,
isTauriRuntime: false,
}))
vi.mock('../api/sessions', () => ({
@ -51,6 +53,14 @@ vi.mock('../api/websocket', () => ({
},
}))
vi.mock('../hooks/useMobileViewport', () => ({
useMobileViewport: () => mocks.isMobile,
}))
vi.mock('../lib/desktopRuntime', () => ({
isTauriRuntime: () => mocks.isTauriRuntime,
}))
vi.mock('../components/shared/DirectoryPicker', () => ({
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onChange('/workspace/project')}>
@ -60,11 +70,19 @@ vi.mock('../components/shared/DirectoryPicker', () => ({
}))
vi.mock('../components/controls/PermissionModeSelector', () => ({
PermissionModeSelector: () => <button type="button">Bypass</button>,
PermissionModeSelector: ({ compact }: { compact?: boolean }) => (
<button type="button" data-testid="permission-mode-selector" data-compact={compact ? 'true' : 'false'}>
Bypass
</button>
),
}))
vi.mock('../components/controls/ModelSelector', () => ({
ModelSelector: () => <button type="button">Model</button>,
ModelSelector: ({ compact }: { compact?: boolean }) => (
<button type="button" data-testid="model-selector" data-compact={compact ? 'true' : 'false'}>
Model
</button>
),
}))
import { EmptySession } from './EmptySession'
@ -126,6 +144,8 @@ describe('EmptySession', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.isMobile = false
mocks.isTauriRuntime = false
useSettingsStore.setState({ locale: 'en', activeProviderName: null })
useSessionStore.setState(initialSessionState, true)
useChatStore.setState(initialChatState, true)
@ -164,6 +184,18 @@ describe('EmptySession', () => {
useUIStore.setState(initialUiState, true)
})
it('uses compact composer controls on phone-sized H5 browsers', async () => {
mocks.isMobile = true
render(<EmptySession />)
await waitFor(() => {
expect(screen.getByTestId('permission-mode-selector')).toHaveAttribute('data-compact', 'true')
})
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-compact', 'true')
expect(screen.getByRole('button', { name: 'Run' })).toHaveClass('h-11', 'w-11')
})
it('creates a session with the selected project and branch when submitted', async () => {
render(<EmptySession />)

View File

@ -15,6 +15,8 @@ import { AttachmentGallery } from '../components/chat/AttachmentGallery'
import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator'
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
import { useMobileViewport } from '../hooks/useMobileViewport'
import { isTauriRuntime } from '../lib/desktopRuntime'
import {
FALLBACK_SLASH_COMMANDS,
findSlashToken,
@ -109,6 +111,7 @@ export function EmptySession() {
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
: undefined
const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
const isMobileComposer = useMobileViewport() && !isTauriRuntime()
useEffect(() => {
textareaRef.current?.focus()
@ -508,22 +511,46 @@ export function EmptySession() {
return (
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
<div className={`flex flex-1 flex-col items-center justify-center ${
isMobileComposer ? 'px-6 pb-[230px] pt-10' : 'p-8 pb-32'
}`}>
<div className={`flex flex-col items-center text-center ${
isMobileComposer ? 'max-w-[300px]' : 'max-w-md'
}`}>
<img
src="/app-icon.png"
alt="Claude Code Haha"
className={isMobileComposer ? 'mb-4 h-16 w-16' : 'mb-6 h-24 w-24'}
/>
<h1
className={`mb-2 font-extrabold tracking-tight text-[var(--color-text-primary)] ${
isMobileComposer ? 'text-2xl' : 'text-3xl'
}`}
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
<p
className={`mx-auto text-[var(--color-text-secondary)] ${
isMobileComposer ? 'max-w-[280px] text-sm leading-6' : 'max-w-xs'
}`}
style={{ fontFamily: 'var(--font-body)' }}
>
{t('empty.subtitle')}
</p>
</div>
</div>
<div className="absolute bottom-4 left-0 right-0 flex justify-center px-8">
<div className="flex w-full max-w-3xl flex-col">
<div className={`absolute left-0 right-0 z-30 flex justify-center ${
isMobileComposer
? 'bottom-0 px-0 pb-0'
: 'bottom-4 px-8'
}`}>
<div className={`flex w-full flex-col ${isMobileComposer ? 'max-w-none' : 'max-w-3xl'}`}>
<div
className="glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4"
className={`glass-panel relative flex flex-col gap-3 ${
isMobileComposer ? 'rounded-t-2xl rounded-b-none p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-t-xl rounded-b-none p-4'
}`}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
@ -622,26 +649,34 @@ export function EmptySession() {
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
className={`flex-1 resize-none border-none bg-transparent leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${
isMobileComposer ? 'max-h-[132px] min-h-[72px] py-1.5 text-base' : 'py-2'
}`}
style={{ fontFamily: 'var(--font-body)' }}
placeholder={t('empty.placeholder')}
rows={2}
/>
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3">
<div className="flex items-center gap-2">
<div className={`border-t border-[var(--color-border-separator)] pt-3 ${
isMobileComposer ? 'flex flex-wrap items-center gap-2' : 'flex items-center justify-between'
}`}>
<div className="flex shrink-0 items-center gap-2">
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((prev) => !prev)}
aria-label="Open composer tools"
className="rounded-lg p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
className={`text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
isMobileComposer ? 'inline-flex h-11 w-11 items-center justify-center rounded-xl' : 'rounded-lg p-1.5'
}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 mb-2 w-[240px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]">
<div className={`absolute bottom-full left-0 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${
isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'
}`}>
<button
onClick={() => {
fileInputRef.current?.click()
@ -663,24 +698,29 @@ export function EmptySession() {
)}
</div>
<PermissionModeSelector workDir={workDir} />
<PermissionModeSelector workDir={workDir} compact={isMobileComposer} />
</div>
<div className="flex items-center gap-3">
<div className={`${isMobileComposer ? 'flex min-w-0 flex-1 items-center justify-end gap-2' : 'flex items-center gap-3'}`}>
<ContextUsageIndicator
chatState="idle"
messageCount={0}
runtimeSelectionKey={draftRuntimeSelectionKey}
fallbackModelLabel={draftModelLabel}
draft
compact={isMobileComposer}
/>
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30"
aria-label={t('common.run')}
title={isMobileComposer ? t('common.run') : undefined}
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30 ${
isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'w-[112px] px-3 py-1.5'
}`}
>
{t('common.run')}
{!isMobileComposer && t('common.run')}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</button>
</div>

View File

@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { startServer } from '../index.js'
import { canBypassRemoteAuthForLocalBrowser, startServer } from '../index.js'
import { H5AccessService } from '../services/h5AccessService.js'
import { ProviderService } from '../services/providerService.js'
@ -118,6 +118,29 @@ describe('remote H5 auth and CORS integration', () => {
})
})
test('allows localhost WebUI origin without H5 token for browser development', async () => {
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: 'http://127.0.0.1:5179',
},
})
expect(response.status).toBe(200)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('http://127.0.0.1:5179')
await expect(response.json()).resolves.toMatchObject({
status: 'ok',
})
})
test('only lets localhost WebUI origin bypass H5 auth for loopback or private server hosts', () => {
expect(canBypassRemoteAuthForLocalBrowser('http://127.0.0.1:5179', '127.0.0.1')).toBe(true)
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', '192.168.0.102')).toBe(true)
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', '10.0.0.5')).toBe(true)
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', '172.20.1.8')).toBe(true)
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', 'public.example.com')).toBe(false)
expect(canBypassRemoteAuthForLocalBrowser('http://192.168.0.50:5179', '192.168.0.102')).toBe(false)
})
test('rejects /api/status when H5 is enabled and bearer token is wrong', async () => {
await enableH5Access()

View File

@ -53,6 +53,49 @@ function isLocalServerHost(host: string): boolean {
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
}
function isLocalBrowserOrigin(origin: string | null): boolean {
if (!origin) return false
try {
return isLocalServerHost(new URL(origin).hostname)
} catch {
return false
}
}
function isPrivateNetworkHost(host: string): boolean {
const normalized = host.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
if (normalized === '0.0.0.0') {
return true
}
const parts = normalized.split('.')
if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) {
const octets = parts.map((part) => Number(part))
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return false
}
const a = octets[0] ?? -1
const b = octets[1] ?? -1
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
)
}
return normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80:')
}
export function canBypassRemoteAuthForLocalBrowser(origin: string | null, requestHost: string): boolean {
return isLocalBrowserOrigin(origin) &&
(isLocalServerHost(requestHost) || isPrivateNetworkHost(requestHost))
}
function withCors(response: Response, cors: CorsResolution): Response {
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(cors.headers)) {
@ -87,10 +130,10 @@ export function startServer(port = PORT, host = HOST) {
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
*/
const authRequired =
const forceAuth =
SERVER_OPTIONS.authRequired ||
process.env.SERVER_AUTH_REQUIRED === '1' ||
!isLocalServerHost(host)
process.env.SERVER_AUTH_REQUIRED === '1'
const remoteHostAuthRequired = !isLocalServerHost(host)
const server = Bun.serve<WebSocketData>({
port,
@ -102,6 +145,10 @@ export function startServer(port = PORT, host = HOST) {
const url = new URL(req.url)
const origin = req.headers.get('Origin')
const cors = await resolveCors(origin)
const authRequired = forceAuth || (
remoteHostAuthRequired &&
!canBypassRemoteAuthForLocalBrowser(origin, url.hostname)
)
// Handle CORS preflight
if (req.method === 'OPTIONS') {