mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
8b5de63ae9
commit
5f9faaf01d
@ -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 />)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
94
desktop/src/components/shared/MobileBottomSheet.tsx
Normal file
94
desktop/src/components/shared/MobileBottomSheet.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
166
desktop/src/components/shared/RepositoryLaunchControls.test.tsx
Normal file
166
desktop/src/components/shared/RepositoryLaunchControls.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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') {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user