mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +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')
|
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 () => {
|
it('EmptySession plus menu exposes uploads and slash commands before chat starts', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(<EmptySession />)
|
render(<EmptySession />)
|
||||||
|
|||||||
@ -725,25 +725,25 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
isHeroComposer
|
isHeroComposer
|
||||||
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
|
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
|
||||||
: compact
|
: compact
|
||||||
? 'border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] px-3 py-3'
|
? `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-3 py-3' : 'px-4 py-4'}`
|
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-0 pb-0 pt-2' : 'px-4 py-4'}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isHeroComposer
|
isHeroComposer
|
||||||
? 'mx-auto flex w-full max-w-3xl flex-col'
|
? 'mx-auto flex w-full max-w-3xl flex-col'
|
||||||
: compact
|
: compact
|
||||||
? 'mx-auto max-w-full'
|
? 'mx-auto max-w-full'
|
||||||
: 'mx-auto max-w-[860px]'
|
: `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[860px]'}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={isHeroComposer
|
className={isHeroComposer
|
||||||
? 'glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4 transition-colors'
|
? 'glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4 transition-colors'
|
||||||
: compact
|
: compact
|
||||||
? 'glass-panel relative rounded-xl p-3 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 rounded-xl p-4 transition-colors'}
|
: `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()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
@ -964,7 +964,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
|||||||
: t('common.run')
|
: t('common.run')
|
||||||
: undefined
|
: 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'
|
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
|
!isMemberSession && isActive
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions'
|
import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { ChatState } from '../../types/chat'
|
import type { ChatState } from '../../types/chat'
|
||||||
|
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
@ -69,6 +70,7 @@ export function ContextUsageIndicator({
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null)
|
const [updatedAt, setUpdatedAt] = useState<number | null>(null)
|
||||||
const [inspectionModel, setInspectionModel] = useState<string | null>(null)
|
const [inspectionModel, setInspectionModel] = useState<string | null>(null)
|
||||||
|
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false)
|
||||||
const requestSeq = useRef(0)
|
const requestSeq = useRef(0)
|
||||||
const contextIdentityRef = useRef('')
|
const contextIdentityRef = useRef('')
|
||||||
|
|
||||||
@ -172,7 +174,12 @@ export function ContextUsageIndicator({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
onClick={() => { void refresh() }}
|
onClick={() => {
|
||||||
|
if (compact) {
|
||||||
|
setMobileDetailsOpen(true)
|
||||||
|
}
|
||||||
|
void refresh()
|
||||||
|
}}
|
||||||
title={t('contextIndicator.title')}
|
title={t('contextIndicator.title')}
|
||||||
data-testid="context-usage-indicator"
|
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)] ${
|
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>
|
</span>
|
||||||
</button>
|
</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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||||
@ -268,6 +277,81 @@ export function ContextUsageIndicator({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import { useSettingsStore } from '../../stores/settingsStore'
|
|||||||
import type { SavedProvider } from '../../types/provider'
|
import type { SavedProvider } from '../../types/provider'
|
||||||
import type { RuntimeSelection } from '../../types/runtime'
|
import type { RuntimeSelection } from '../../types/runtime'
|
||||||
import type { EffortLevel, ModelInfo } from '../../types/settings'
|
import type { EffortLevel, ModelInfo } from '../../types/settings'
|
||||||
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||||
|
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||||
|
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
||||||
|
|
||||||
type ProviderChoice = {
|
type ProviderChoice = {
|
||||||
providerId: string | null
|
providerId: string | null
|
||||||
@ -127,6 +130,7 @@ export function ModelSelector({
|
|||||||
compact = false,
|
compact = false,
|
||||||
}: Props = {}) {
|
}: Props = {}) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
|
||||||
const {
|
const {
|
||||||
currentModel: storeModel,
|
currentModel: storeModel,
|
||||||
availableModels,
|
availableModels,
|
||||||
@ -306,8 +310,172 @@ export function ModelSelector({
|
|||||||
setOpen(false)
|
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
|
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
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
data-testid="model-selector-dropdown"
|
data-testid="model-selector-dropdown"
|
||||||
@ -318,160 +486,19 @@ export function ModelSelector({
|
|||||||
width: dropdownPosition.width,
|
width: dropdownPosition.width,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-y-auto p-3" style={{ maxHeight: dropdownPosition.maxHeight }}>
|
{dropdownContent}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative min-w-0 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => !disabled && setOpen(!open)}
|
onClick={() => !disabled && setOpen(!open)}
|
||||||
disabled={disabled}
|
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 ${
|
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">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
|||||||
@ -38,6 +38,7 @@ vi.mock('../../i18n', () => ({
|
|||||||
'permMode.permPackages': 'Install packages',
|
'permMode.permPackages': 'Install packages',
|
||||||
'permMode.enableBypassBtn': 'Enable bypass',
|
'permMode.enableBypassBtn': 'Enable bypass',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
|
'tabs.close': 'Close',
|
||||||
}[key] ?? key),
|
}[key] ?? key),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -68,7 +69,8 @@ describe('PermissionModeSelector mobile access', () => {
|
|||||||
|
|
||||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||||
expect(trigger).toHaveAttribute('aria-controls', 'permission-mode-menu')
|
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()
|
expect(screen.getByRole('menuitem', { name: /Auto accept edits/ })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import type { PermissionMode } from '../../types/settings'
|
import type { PermissionMode } from '../../types/settings'
|
||||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||||
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||||
|
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
||||||
|
|
||||||
const MODE_ICONS: Record<PermissionMode, string> = {
|
const MODE_ICONS: Record<PermissionMode, string> = {
|
||||||
default: 'verified_user',
|
default: 'verified_user',
|
||||||
@ -118,11 +119,8 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const menuContent = (
|
const permissionOptions = (
|
||||||
<>
|
<div id={menuId} ref={menuRef} role="menu">
|
||||||
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
|
||||||
{t('permMode.executionPermissions')}
|
|
||||||
</div>
|
|
||||||
{PERMISSION_ITEMS.map((item) => (
|
{PERMISSION_ITEMS.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@ -142,25 +140,34 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
className={`
|
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)]
|
hover:bg-[var(--color-surface-hover)]
|
||||||
${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''}
|
${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}
|
{item.icon}
|
||||||
</span>
|
</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-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>
|
</div>
|
||||||
{item.value === currentMode && (
|
{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
|
check_circle
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
isMobile ? createPortal(
|
isMobile ? (
|
||||||
<div className="fixed inset-0 z-50 bg-black/20" onClick={() => setOpen(false)}>
|
<MobileBottomSheet
|
||||||
<div
|
open={open}
|
||||||
id={menuId}
|
onClose={() => setOpen(false)}
|
||||||
ref={menuRef}
|
title={t('permMode.executionPermissions')}
|
||||||
role="menu"
|
closeLabel={t('tabs.close')}
|
||||||
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)]"
|
ariaLabel={t('permMode.executionPermissions')}
|
||||||
onClick={(event) => event.stopPropagation()}
|
contentClassName="py-2"
|
||||||
>
|
>
|
||||||
{menuContent}
|
{permissionOptions}
|
||||||
</div>
|
</MobileBottomSheet>
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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}
|
{menuContent}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
fetchAll: vi.fn(),
|
fetchAll: vi.fn(),
|
||||||
restoreTabs: vi.fn(),
|
restoreTabs: vi.fn(),
|
||||||
connectToSession: vi.fn(),
|
connectToSession: vi.fn(),
|
||||||
|
setActiveTab: vi.fn(),
|
||||||
tabState: {
|
tabState: {
|
||||||
activeTabId: null as string | null,
|
activeTabId: null as string | null,
|
||||||
tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>,
|
tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>,
|
||||||
@ -32,17 +33,31 @@ vi.mock('../../hooks/useMobileViewport', () => ({
|
|||||||
useMobileViewport: () => mocks.isMobile,
|
useMobileViewport: () => mocks.isMobile,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../stores/tabStore', () => ({
|
vi.mock('../../stores/tabStore', () => {
|
||||||
SETTINGS_TAB_ID: '__settings__',
|
const useTabStore = (selector: (state: {
|
||||||
useTabStore: {
|
tabs: typeof mocks.tabState.tabs
|
||||||
getState: () => ({
|
activeTabId: string | null
|
||||||
restoreTabs: mocks.restoreTabs,
|
setActiveTab: typeof mocks.setActiveTab
|
||||||
activeTabId: mocks.tabState.activeTabId,
|
}) => unknown) => selector({
|
||||||
tabs: mocks.tabState.tabs,
|
tabs: mocks.tabState.tabs,
|
||||||
openTab: vi.fn(),
|
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', () => ({
|
vi.mock('../../stores/chatStore', () => ({
|
||||||
useChatStore: {
|
useChatStore: {
|
||||||
@ -100,6 +115,9 @@ describe('AppShell boot flow', () => {
|
|||||||
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
|
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
|
||||||
mocks.fetchAll.mockResolvedValue(undefined)
|
mocks.fetchAll.mockResolvedValue(undefined)
|
||||||
mocks.restoreTabs.mockResolvedValue(undefined)
|
mocks.restoreTabs.mockResolvedValue(undefined)
|
||||||
|
mocks.setActiveTab.mockImplementation((sessionId: string) => {
|
||||||
|
mocks.tabState.activeTabId = sessionId
|
||||||
|
})
|
||||||
mocks.tabState.activeTabId = null
|
mocks.tabState.activeTabId = null
|
||||||
mocks.tabState.tabs = []
|
mocks.tabState.tabs = []
|
||||||
useUIStore.setState({ sidebarOpen: true })
|
useUIStore.setState({ sidebarOpen: true })
|
||||||
@ -252,4 +270,21 @@ describe('AppShell boot flow', () => {
|
|||||||
expect(useUIStore.getState().sidebarOpen).toBe(false)
|
expect(useUIStore.getState().sidebarOpen).toBe(false)
|
||||||
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed')
|
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 { useTranslation } from '../../i18n'
|
||||||
import { H5ConnectionView } from './H5ConnectionView'
|
import { H5ConnectionView } from './H5ConnectionView'
|
||||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||||
|
import type { Tab } from '../../stores/tabStore'
|
||||||
|
|
||||||
|
function isChatTab(tab: Tab | undefined) {
|
||||||
|
return tab?.type === 'session'
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||||
@ -33,6 +38,9 @@ export function AppShell() {
|
|||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const tauriRuntime = isTauriRuntime()
|
const tauriRuntime = isTauriRuntime()
|
||||||
const isMobileShell = useMobileViewport() && !tauriRuntime
|
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 wasMobileShellRef = useRef(false)
|
||||||
const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen
|
const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen
|
||||||
const sidebarHiddenProps: HTMLAttributes<HTMLDivElement> & { inert?: '' } =
|
const sidebarHiddenProps: HTMLAttributes<HTMLDivElement> & { inert?: '' } =
|
||||||
@ -120,6 +128,18 @@ export function AppShell() {
|
|||||||
wasMobileShellRef.current = isMobileShell
|
wasMobileShellRef.current = isMobileShell
|
||||||
}, [isMobileShell, setSidebarOpen])
|
}, [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) => {
|
const setEffectiveSidebarOpen = (open: boolean) => {
|
||||||
if (isMobileShell) {
|
if (isMobileShell) {
|
||||||
setMobileSidebarOpen(open)
|
setMobileSidebarOpen(open)
|
||||||
@ -204,7 +224,7 @@ export function AppShell() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<TabBar />
|
{!isMobileShell ? <TabBar /> : null}
|
||||||
<ContentRouter />
|
<ContentRouter />
|
||||||
</main>
|
</main>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|||||||
@ -204,7 +204,7 @@ describe('Sidebar', () => {
|
|||||||
expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col')
|
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()
|
const onRequestClose = vi.fn()
|
||||||
createSession.mockResolvedValue('session-mobile-new')
|
createSession.mockResolvedValue('session-mobile-new')
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
@ -224,11 +224,11 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
render(<Sidebar isMobile onRequestClose={onRequestClose} />)
|
render(<Sidebar isMobile onRequestClose={onRequestClose} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }))
|
expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument()
|
||||||
expect(onRequestClose).toHaveBeenCalledTimes(1)
|
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
|
||||||
expect(onRequestClose).toHaveBeenCalledTimes(2)
|
expect(onRequestClose).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
|
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
|
||||||
@ -237,7 +237,7 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createSession).toHaveBeenCalled()
|
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', () => {
|
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')}
|
{t('sidebar.newSession')}
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem
|
{!isMobile && (
|
||||||
active={activeTabId === SCHEDULED_TAB_ID}
|
<NavItem
|
||||||
collapsed={!expanded}
|
active={activeTabId === SCHEDULED_TAB_ID}
|
||||||
label={t('sidebar.scheduled')}
|
collapsed={!expanded}
|
||||||
touchFriendly={isMobile}
|
label={t('sidebar.scheduled')}
|
||||||
onClick={() => {
|
touchFriendly={isMobile}
|
||||||
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
|
onClick={() => {
|
||||||
closeMobileDrawer()
|
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
|
||||||
}}
|
closeMobileDrawer()
|
||||||
icon={<ClockIcon />}
|
}}
|
||||||
>
|
icon={<ClockIcon />}
|
||||||
{t('sidebar.scheduled')}
|
>
|
||||||
</NavItem>
|
{t('sidebar.scheduled')}
|
||||||
|
</NavItem>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
@ -359,21 +361,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|||||||
<div className="flex-1" aria-hidden="true" />
|
<div className="flex-1" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}>
|
{!isMobile && (
|
||||||
<NavItem
|
<div className={`border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}>
|
||||||
active={activeTabId === SETTINGS_TAB_ID}
|
<NavItem
|
||||||
collapsed={!expanded}
|
active={activeTabId === SETTINGS_TAB_ID}
|
||||||
label={t('sidebar.settings')}
|
collapsed={!expanded}
|
||||||
touchFriendly={isMobile}
|
label={t('sidebar.settings')}
|
||||||
onClick={() => {
|
touchFriendly={isMobile}
|
||||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
|
onClick={() => {
|
||||||
closeMobileDrawer()
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
|
||||||
}}
|
closeMobileDrawer()
|
||||||
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
|
}}
|
||||||
>
|
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
|
||||||
{t('sidebar.settings')}
|
>
|
||||||
</NavItem>
|
{t('sidebar.settings')}
|
||||||
</div>
|
</NavItem>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{contextMenu && sidebarOpen && (
|
{contextMenu && sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { createPortal } from 'react-dom'
|
|||||||
import { sessionsApi, type RecentProject } from '../../api/sessions'
|
import { sessionsApi, type RecentProject } from '../../api/sessions'
|
||||||
import { filesystemApi } from '../../api/filesystem'
|
import { filesystemApi } from '../../api/filesystem'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||||
|
import { MobileBottomSheet } from './MobileBottomSheet'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string
|
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 [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
|
||||||
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
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 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'
|
: '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 (
|
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 */}
|
{/* Trigger — shows selected project chip or placeholder */}
|
||||||
{value ? (
|
{value ? (
|
||||||
<button
|
<button
|
||||||
@ -190,144 +326,27 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dropdown — rendered via portal to escape overflow clipping */}
|
{isOpen && dropdownPos && (
|
||||||
{isOpen && dropdownPos && createPortal(
|
isMobileBrowser ? (
|
||||||
<div
|
<MobileBottomSheet
|
||||||
ref={dropdownRef}
|
open={isOpen}
|
||||||
className="w-[400px] bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] rounded-xl shadow-[var(--shadow-dropdown)] overflow-hidden"
|
onClose={() => setIsOpen(false)}
|
||||||
style={{
|
title={dropdownTitle}
|
||||||
position: 'fixed',
|
closeLabel={t('tabs.close')}
|
||||||
left: dropdownPos.left,
|
panelRef={dropdownRef}
|
||||||
...(dropdownPos.direction === 'down'
|
>
|
||||||
? { top: dropdownPos.top }
|
{dropdownContent}
|
||||||
: { bottom: window.innerHeight - dropdownPos.top }),
|
</MobileBottomSheet>
|
||||||
zIndex: 9999,
|
) : createPortal(
|
||||||
}}
|
<div
|
||||||
>
|
ref={dropdownRef}
|
||||||
{mode === 'recent' ? (
|
className={dropdownClassName}
|
||||||
<>
|
style={dropdownStyle}
|
||||||
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
>
|
||||||
{t('dirPicker.recent')}
|
{dropdownContent}
|
||||||
</div>
|
</div>,
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
document.body,
|
||||||
{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
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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'
|
} from '../../api/sessions'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { DirectoryPicker } from './DirectoryPicker'
|
import { DirectoryPicker } from './DirectoryPicker'
|
||||||
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||||
|
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||||
|
import { MobileBottomSheet } from './MobileBottomSheet'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workDir: string
|
workDir: string
|
||||||
@ -54,6 +57,7 @@ export function RepositoryLaunchControls({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
|
||||||
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -287,12 +291,55 @@ export function RepositoryLaunchControls({
|
|||||||
const worktreeLabel = useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')
|
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 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 (
|
return (
|
||||||
<div ref={rootRef} className="flex min-w-0 flex-col gap-2">
|
<div ref={rootRef} className={`flex min-w-0 flex-col ${isMobileBrowser ? 'gap-0' : '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 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} />
|
<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)]">
|
<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" />
|
<Loader2 size={14} className="shrink-0 animate-spin" />
|
||||||
<span>{t('common.loading')}</span>
|
<span>{t('common.loading')}</span>
|
||||||
@ -315,7 +362,7 @@ export function RepositoryLaunchControls({
|
|||||||
setWorktreeMenuOpen(false)
|
setWorktreeMenuOpen(false)
|
||||||
setBranchFilter('')
|
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)]" />
|
<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)]">
|
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
|
||||||
@ -337,7 +384,7 @@ export function RepositoryLaunchControls({
|
|||||||
setWorktreeMenuOpen((prev) => !prev)
|
setWorktreeMenuOpen((prev) => !prev)
|
||||||
setBranchMenuOpen(false)
|
setBranchMenuOpen(false)
|
||||||
}}
|
}}
|
||||||
className={`${workbarButtonClassName} shrink-0 ${
|
className={`${workbarButtonClassName} shrink-0 ${isMobileBrowser ? 'bg-[var(--color-surface-container)]' : ''} ${
|
||||||
useWorktree
|
useWorktree
|
||||||
? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
|
? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
|
||||||
: ''
|
: ''
|
||||||
@ -371,98 +418,201 @@ export function RepositoryLaunchControls({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{branchMenuOpen && menuPos && createPortal(
|
{branchMenuOpen && menuPos && (
|
||||||
<div
|
isMobileBrowser ? (
|
||||||
ref={menuRef}
|
<MobileBottomSheet
|
||||||
className="w-[390px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
open={branchMenuOpen}
|
||||||
style={{
|
onClose={() => setBranchMenuOpen(false)}
|
||||||
position: 'fixed',
|
title={t('repoLaunch.selectBranch')}
|
||||||
left: menuPos.left,
|
closeLabel={t('tabs.close')}
|
||||||
...(menuPos.direction === 'down'
|
panelRef={menuRef}
|
||||||
? { top: menuPos.top }
|
headerExtra={(
|
||||||
: { bottom: window.innerHeight - menuPos.top }),
|
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
|
||||||
zIndex: 9999,
|
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
}}
|
<input
|
||||||
>
|
id={searchInputId}
|
||||||
<div className="border-b border-[var(--color-border)] p-3">
|
ref={searchRef}
|
||||||
<label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
value={branchFilter}
|
||||||
{t('repoLaunch.selectBranch')}
|
onChange={(event) => setBranchFilter(event.target.value)}
|
||||||
</label>
|
onKeyDown={handleBranchKeyDown}
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
|
aria-controls={listboxId}
|
||||||
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined}
|
||||||
<input
|
placeholder={t('repoLaunch.searchBranch')}
|
||||||
id={searchInputId}
|
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||||
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>
|
</div>
|
||||||
) : filteredBranches.map((candidate, index) => {
|
)}
|
||||||
const isSelected = candidate.name === selectedBranch?.name
|
>
|
||||||
return (
|
<div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="py-1">
|
||||||
<button
|
{filteredBranches.length === 0 ? (
|
||||||
key={candidate.name}
|
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||||
id={`${listboxId}-option-${index}`}
|
{t('repoLaunch.noBranchMatch')}
|
||||||
ref={(el) => { itemRefs.current[index] = el }}
|
</div>
|
||||||
type="button"
|
) : filteredBranches.map((candidate, index) => {
|
||||||
role="option"
|
const isSelected = candidate.name === selectedBranch?.name
|
||||||
aria-selected={isSelected}
|
return (
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
<button
|
||||||
onClick={() => selectBranch(candidate)}
|
key={candidate.name}
|
||||||
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 ${
|
id={`${listboxId}-option-${index}`}
|
||||||
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
ref={(el) => { itemRefs.current[index] = el }}
|
||||||
}`}
|
type="button"
|
||||||
>
|
role="option"
|
||||||
<span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} />
|
aria-selected={isSelected}
|
||||||
<GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" />
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
<span className="min-w-0 flex-1">
|
onClick={() => selectBranch(candidate)}
|
||||||
<span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
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 ${
|
||||||
{candidate.name}
|
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>
|
||||||
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
|
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
|
||||||
{candidate.current
|
</button>
|
||||||
? t('repoLaunch.currentBranch')
|
)
|
||||||
: candidate.checkedOut
|
})}
|
||||||
? t('repoLaunch.checkedOut')
|
</div>
|
||||||
: candidate.remote && !candidate.local
|
</MobileBottomSheet>
|
||||||
? candidate.remoteRef || t('repoLaunch.remoteBranch')
|
) : createPortal(
|
||||||
: t('repoLaunch.localBranch')}
|
<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>
|
||||||
</span>
|
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
|
||||||
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
|
</button>
|
||||||
</button>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body,
|
||||||
document.body,
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{worktreeMenuOpen && worktreeMenuPos && createPortal(
|
{worktreeMenuOpen && worktreeMenuPos && (
|
||||||
<div
|
isMobileBrowser ? (
|
||||||
ref={worktreeMenuRef}
|
<MobileBottomSheet
|
||||||
className="w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
|
open={worktreeMenuOpen}
|
||||||
style={{
|
onClose={() => setWorktreeMenuOpen(false)}
|
||||||
position: 'fixed',
|
title={t('repoLaunch.selectWorktree')}
|
||||||
left: worktreeMenuPos.left,
|
closeLabel={t('tabs.close')}
|
||||||
...(worktreeMenuPos.direction === 'down'
|
panelRef={worktreeMenuRef}
|
||||||
? { top: worktreeMenuPos.top }
|
contentClassName="py-2"
|
||||||
: { bottom: window.innerHeight - worktreeMenuPos.top }),
|
>
|
||||||
zIndex: 9999,
|
<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')}>
|
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -502,6 +652,7 @@ export function RepositoryLaunchControls({
|
|||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
initializeDesktopServerUrl,
|
initializeDesktopServerUrl,
|
||||||
isLoopbackHostname,
|
isLoopbackHostname,
|
||||||
requiresH5AuthForServerUrl,
|
requiresH5AuthForServerUrl,
|
||||||
|
saveAndVerifyH5Connection,
|
||||||
} from './desktopRuntime'
|
} from './desktopRuntime'
|
||||||
|
|
||||||
describe('desktopRuntime browser H5 bootstrap', () => {
|
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://127.0.0.1:3456')).toBe(false)
|
||||||
expect(requiresH5AuthForServerUrl('http://localhost: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')).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 () => {
|
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(
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
new Response(null, { status: 200 }),
|
new Response(null, { status: 200 }),
|
||||||
) as typeof fetch
|
) as typeof fetch
|
||||||
@ -60,7 +68,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
|
|||||||
Object.assign(new Error('Invalid or missing H5 access token'), { status: 401 }),
|
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',
|
name: 'H5ConnectionRequiredError',
|
||||||
serverUrl: 'https://public.example.com/app',
|
serverUrl: 'https://public.example.com/app',
|
||||||
message: 'The saved H5 token is no longer valid.',
|
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 () => {
|
it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => {
|
||||||
vi.useFakeTimers()
|
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
|
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',
|
name: 'H5ConnectionRequiredError',
|
||||||
serverUrl: 'https://unreachable.example.com',
|
serverUrl: 'https://unreachable.example.com',
|
||||||
message: 'Unable to reach https://unreachable.example.com. Check the server URL or network access.',
|
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 () => {
|
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(
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||||
new Response(null, { status: 200 }),
|
new Response(null, { status: 200 }),
|
||||||
) as typeof fetch
|
) as typeof fetch
|
||||||
clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to 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',
|
name: 'H5ConnectionRequiredError',
|
||||||
serverUrl: 'https://public.example.com',
|
serverUrl: 'https://public.example.com',
|
||||||
message: 'Unable to verify the H5 access token.',
|
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()
|
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) {
|
if (!browserH5Runtime) {
|
||||||
|
await ensureBrowserApiAccessibleWithoutH5(requestedUrl)
|
||||||
return requestedUrl
|
return requestedUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,6 +204,19 @@ async function verifyH5Access() {
|
|||||||
await api.post<{ ok: true }>('/api/h5-access/verify')
|
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) {
|
function normalizeServerUrl(value: string | null | undefined) {
|
||||||
const trimmed = value?.trim()
|
const trimmed = value?.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@ -224,14 +238,54 @@ export function isLoopbackHostname(hostname: string) {
|
|||||||
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
|
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requiresH5AuthForServerUrl(serverUrl: string) {
|
export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return false
|
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) {
|
function normalizeBrowserH5Error(error: unknown, serverUrl: string) {
|
||||||
if (error instanceof H5ConnectionRequiredError) {
|
if (error instanceof H5ConnectionRequiredError) {
|
||||||
return error
|
return error
|
||||||
|
|||||||
@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
wsOnMessage: vi.fn(),
|
wsOnMessage: vi.fn(),
|
||||||
wsSend: vi.fn(),
|
wsSend: vi.fn(),
|
||||||
wsDisconnect: vi.fn(),
|
wsDisconnect: vi.fn(),
|
||||||
|
isMobile: false,
|
||||||
|
isTauriRuntime: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../api/sessions', () => ({
|
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', () => ({
|
vi.mock('../components/shared/DirectoryPicker', () => ({
|
||||||
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
|
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
|
||||||
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onChange('/workspace/project')}>
|
<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', () => ({
|
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', () => ({
|
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'
|
import { EmptySession } from './EmptySession'
|
||||||
@ -126,6 +144,8 @@ describe('EmptySession', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mocks.isMobile = false
|
||||||
|
mocks.isTauriRuntime = false
|
||||||
useSettingsStore.setState({ locale: 'en', activeProviderName: null })
|
useSettingsStore.setState({ locale: 'en', activeProviderName: null })
|
||||||
useSessionStore.setState(initialSessionState, true)
|
useSessionStore.setState(initialSessionState, true)
|
||||||
useChatStore.setState(initialChatState, true)
|
useChatStore.setState(initialChatState, true)
|
||||||
@ -164,6 +184,18 @@ describe('EmptySession', () => {
|
|||||||
useUIStore.setState(initialUiState, true)
|
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 () => {
|
it('creates a session with the selected project and branch when submitted', async () => {
|
||||||
render(<EmptySession />)
|
render(<EmptySession />)
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import { AttachmentGallery } from '../components/chat/AttachmentGallery'
|
|||||||
import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator'
|
import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator'
|
||||||
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
|
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
|
||||||
import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
|
import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
|
||||||
|
import { useMobileViewport } from '../hooks/useMobileViewport'
|
||||||
|
import { isTauriRuntime } from '../lib/desktopRuntime'
|
||||||
import {
|
import {
|
||||||
FALLBACK_SLASH_COMMANDS,
|
FALLBACK_SLASH_COMMANDS,
|
||||||
findSlashToken,
|
findSlashToken,
|
||||||
@ -109,6 +111,7 @@ export function EmptySession() {
|
|||||||
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
|
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
|
||||||
: undefined
|
: undefined
|
||||||
const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
|
const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
|
||||||
|
const isMobileComposer = useMobileViewport() && !isTauriRuntime()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
@ -508,22 +511,46 @@ export function EmptySession() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
|
<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 flex-1 flex-col items-center justify-center ${
|
||||||
<div className="flex max-w-md flex-col items-center text-center">
|
isMobileComposer ? 'px-6 pb-[230px] pt-10' : 'p-8 pb-32'
|
||||||
<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-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')}
|
{t('empty.title')}
|
||||||
</h1>
|
</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')}
|
{t('empty.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center px-8">
|
<div className={`absolute left-0 right-0 z-30 flex justify-center ${
|
||||||
<div className="flex w-full max-w-3xl flex-col">
|
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
|
<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()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
@ -622,26 +649,34 @@ export function EmptySession() {
|
|||||||
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
|
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
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)' }}
|
style={{ fontFamily: 'var(--font-body)' }}
|
||||||
placeholder={t('empty.placeholder')}
|
placeholder={t('empty.placeholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3">
|
<div className={`border-t border-[var(--color-border-separator)] pt-3 ${
|
||||||
<div className="flex items-center gap-2">
|
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">
|
<div ref={plusMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlusMenuOpen((prev) => !prev)}
|
onClick={() => setPlusMenuOpen((prev) => !prev)}
|
||||||
aria-label="Open composer tools"
|
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>
|
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{plusMenuOpen && (
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click()
|
||||||
@ -663,24 +698,29 @@ export function EmptySession() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PermissionModeSelector workDir={workDir} />
|
<PermissionModeSelector workDir={workDir} compact={isMobileComposer} />
|
||||||
</div>
|
</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
|
<ContextUsageIndicator
|
||||||
chatState="idle"
|
chatState="idle"
|
||||||
messageCount={0}
|
messageCount={0}
|
||||||
runtimeSelectionKey={draftRuntimeSelectionKey}
|
runtimeSelectionKey={draftRuntimeSelectionKey}
|
||||||
fallbackModelLabel={draftModelLabel}
|
fallbackModelLabel={draftModelLabel}
|
||||||
draft
|
draft
|
||||||
|
compact={isMobileComposer}
|
||||||
/>
|
/>
|
||||||
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
|
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit}
|
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>
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|||||||
import * as fs from 'node:fs/promises'
|
import * as fs from 'node:fs/promises'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { startServer } from '../index.js'
|
import { canBypassRemoteAuthForLocalBrowser, startServer } from '../index.js'
|
||||||
import { H5AccessService } from '../services/h5AccessService.js'
|
import { H5AccessService } from '../services/h5AccessService.js'
|
||||||
import { ProviderService } from '../services/providerService.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 () => {
|
test('rejects /api/status when H5 is enabled and bearer token is wrong', async () => {
|
||||||
await enableH5Access()
|
await enableH5Access()
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,49 @@ function isLocalServerHost(host: string): boolean {
|
|||||||
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
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 {
|
function withCors(response: Response, cors: CorsResolution): Response {
|
||||||
const headers = new Headers(response.headers)
|
const headers = new Headers(response.headers)
|
||||||
for (const [key, value] of Object.entries(cors.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.
|
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
|
||||||
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
|
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
|
||||||
*/
|
*/
|
||||||
const authRequired =
|
const forceAuth =
|
||||||
SERVER_OPTIONS.authRequired ||
|
SERVER_OPTIONS.authRequired ||
|
||||||
process.env.SERVER_AUTH_REQUIRED === '1' ||
|
process.env.SERVER_AUTH_REQUIRED === '1'
|
||||||
!isLocalServerHost(host)
|
const remoteHostAuthRequired = !isLocalServerHost(host)
|
||||||
|
|
||||||
const server = Bun.serve<WebSocketData>({
|
const server = Bun.serve<WebSocketData>({
|
||||||
port,
|
port,
|
||||||
@ -102,6 +145,10 @@ export function startServer(port = PORT, host = HOST) {
|
|||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const origin = req.headers.get('Origin')
|
const origin = req.headers.get('Origin')
|
||||||
const cors = await resolveCors(origin)
|
const cors = await resolveCors(origin)
|
||||||
|
const authRequired = forceAuth || (
|
||||||
|
remoteHostAuthRequired &&
|
||||||
|
!canBypassRemoteAuthForLocalBrowser(origin, url.hostname)
|
||||||
|
)
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user