sync: update from private repo (ea88916)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-08 03:58:10 +00:00
parent 0f75bdfbab
commit 38bd874366
14 changed files with 1500 additions and 303 deletions
+39 -57
View File
@@ -10,7 +10,7 @@ import { useLocalTaskList } from './hooks/useTaskList';
import { useLocalTask, useLocalTaskComments } from './hooks/useTaskDetail';
import { useSubtaskActivities } from './hooks/useSubtaskActivities';
import { useBranding } from './hooks/useBranding';
import { useSwipeNav } from './hooks/useSwipeNav';
import { SwipeableTabs } from './components/mobile/SwipeableTabs';
import { useLocalStorageState } from './hooks/useLocalStorageState';
import { useTaskNotifications } from './hooks/useTaskNotifications';
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
@@ -477,7 +477,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
</div>
) : (
<MobileDetailFlow mobileTab={mobileTab} onTabChange={(id) => setUrlState(prev => ({ ...prev, mobileTab: id }))} onSwipeRightFromEdge={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))} visibleTabs={mobileVisibleTabIds}>
<MobileDetailFlow>
<div className="flex-shrink-0 flex border-b border-hairline bg-canvas px-2 pt-[env(safe-area-inset-top)]">
{mobileVisibleTabs.map(({ id, label }) => (
<button
@@ -502,29 +502,38 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</svg>
</button>
</div>
<div key={mobileTab} className="flex-1 min-h-0 overflow-hidden animate-mobile-tab-swap">
{mobileTab === 'chat' && (
chatReady ? (
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
) : (
<SkeletonChatPane />
)
)}
{mobileTab !== 'chat' && localTaskId && (
<LocalDetailPanel
{...detailPanelProps({
detailTab: mobileTab === 'overview' ? 'overview'
: mobileTab === 'activity' ? 'activity'
: mobileTab === 'trace' ? 'trace'
: mobileTab === 'browser' ? 'browser'
: mobileTab === 'ssh' ? 'ssh'
: 'files',
showWidthToggle: false,
onTabChange: t => setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })),
onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })),
})}
/>
)}
<div className="flex-1 min-h-0 overflow-hidden">
<SwipeableTabs
tabs={mobileVisibleTabIds}
activeTab={mobileTab}
onTabChange={(id) => setUrlState(prev => ({ ...prev, mobileTab: id }))}
onSwipeBackFromFirst={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))}
renderTab={(id, preview) => (
// While dragging, browser (noVNC iframe) / ssh (WebSocket)
// peek as a light placeholder; the real panel mounts on commit.
preview && (id === 'browser' || id === 'ssh')
? <div className="h-full w-full flex items-center justify-center bg-canvas text-slate-400 text-sm font-medium">{id === 'browser' ? 'ブラウザ' : 'SSH'}</div>
: id === 'chat'
? (chatReady
? <ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
: <SkeletonChatPane />)
: (localTaskId
? <LocalDetailPanel
{...detailPanelProps({
detailTab: id === 'overview' ? 'overview'
: id === 'activity' ? 'activity'
: id === 'trace' ? 'trace'
: id === 'browser' ? 'browser'
: id === 'ssh' ? 'ssh'
: 'files',
showWidthToggle: false,
onTabChange: t => setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })),
onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })),
})}
/>
: null)
)}
/>
</div>
{/* Mobile-only pet overlay. Anchored to the MobileDetailFlow
wrapper (which has `relative`) so the pet stays visible
@@ -684,39 +693,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
* via the tab bar still works (the swipe handler ignores touches that
* start on form controls / buttons / anchors).
*/
function MobileDetailFlow({
mobileTab,
onTabChange,
onSwipeRightFromEdge,
visibleTabs,
children,
}: {
mobileTab: MobileTabId;
onTabChange: (tab: MobileTabId) => void;
onSwipeRightFromEdge?: () => void;
visibleTabs: MobileTabId[];
children: ReactNode;
}) {
const swipe = useSwipeNav({
onSwipeLeft: () => {
const idx = visibleTabs.indexOf(mobileTab);
if (idx >= 0 && idx < visibleTabs.length - 1) {
onTabChange(visibleTabs[idx + 1]);
}
},
onSwipeRight: () => {
const idx = visibleTabs.indexOf(mobileTab);
if (idx > 0) {
onTabChange(visibleTabs[idx - 1]);
} else if (idx === 0) {
onSwipeRightFromEdge?.();
}
},
});
// `relative` is required so the app-level mobile pet overlay (rendered
// inside this wrapper) can anchor with position: absolute.
function MobileDetailFlow({ children }: { children: ReactNode }) {
// The horizontal tab swipe now lives in <SwipeableTabs> (the content area)
// so it can yield to native scroll inside wide content and follow the finger.
// This wrapper only provides `relative` for the app-level mobile pet overlay.
return (
<div className="relative flex flex-col h-full" {...swipe}>
<div className="relative flex flex-col h-full">
{children}
</div>
);
+211 -5
View File
@@ -1,14 +1,53 @@
import { useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useConsoleSession } from '../../../hooks/useConsoleSession';
import type { ConsoleStatus } from '../../../lib/ssh-console-types';
import type { SshConnection } from '../../../lib/ssh-types';
import { TerminalView, type TerminalViewHandle } from './console/TerminalView';
import { ConsoleHeader } from './console/ConsoleHeader';
import { MobileKeyboardBar } from './console/MobileKeyboardBar';
import { ScrollToBottomButton } from './console/ScrollToBottomButton';
import { useViewportNarrow } from '../../layout/TopBar';
async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
const res = await fetch('/api/ssh/connections', { credentials: 'include' });
if (res.status === 404) return { list: [], sshDisabled: true };
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { connections: SshConnection[] };
return { list: data.connections ?? [], sshDisabled: false };
}
/** Map a structured `{ error }` code from the session endpoint to a user-facing message. */
function describeSessionError(code: string): { msg: string; hardStop: boolean } {
switch (code) {
case 'host_key_not_verified':
return {
msg: '接続の host key を検証してください(Settings → SSH Connections → Test',
hardStop: false,
};
case 'no_grant':
return {
msg: 'この接続への権限がありません(admin に grant を依頼してください)',
hardStop: false,
};
case 'host_key_mismatch':
return {
msg: 'host key 不一致(MITM の可能性)。admin に連絡してください',
hardStop: true,
};
case 'connection_disabled':
return { msg: 'この接続は無効化されています', hardStop: false };
case 'abuse_locked':
return { msg: 'この接続は一時的にロックされています(abuse 検知)', hardStop: false };
case 'connection_not_found':
return { msg: '接続が見つかりません', hardStop: false };
default:
return { msg: `セッションを開始できませんでした: ${code}`, hardStop: false };
}
}
export function ConsoleTab({ taskId }: { taskId: number }) {
const qc = useQueryClient();
const { data: status } = useQuery<ConsoleStatus>({
queryKey: ['console-status', taskId],
queryFn: async () => {
@@ -24,14 +63,181 @@ export function ConsoleTab({ taskId }: { taskId: number }) {
// and scroll-to-bottom FAB become useful.
const compactMode = useViewportNarrow(768);
const showPicker = !status?.active;
return (
<div className="flex flex-col flex-1 min-h-0">
<ConsoleHeader state={session.state} status={status ?? null} />
<div className="flex-1 min-h-0 relative">
<TerminalView ref={terminalRef} session={session} />
{compactMode && <ScrollToBottomButton terminalRef={terminalRef} />}
{showPicker ? (
<ConnectionPicker
taskId={taskId}
onStarted={() => {
// The session now exists server-side; refresh status and force
// an immediate WS attach instead of waiting for the 5s poll.
qc.invalidateQueries({ queryKey: ['console-status', taskId] });
session.reconnectNow();
}}
/>
) : (
<>
<TerminalView ref={terminalRef} session={session} />
{compactMode && <ScrollToBottomButton terminalRef={terminalRef} />}
</>
)}
</div>
{compactMode && !showPicker && <MobileKeyboardBar session={session} />}
</div>
);
}
function ConnectionPicker({
taskId,
onStarted,
}: {
taskId: number;
onStarted: () => void;
}) {
const { data, isLoading, error } = useQuery({
queryKey: ['ssh', 'connections'],
queryFn: fetchConnections,
staleTime: 15_000,
});
const connections = useMemo(
() => (data?.list ?? []).filter((c) => c.enabled && !c.disabledByAdmin),
[data],
);
const [selectedId, setSelectedId] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [errMsg, setErrMsg] = useState<{ msg: string; hardStop: boolean } | null>(null);
// Set when the server reports an existing session on a different connection;
// lets the user re-POST with force_replace to take over the session.
const [replaceCandidate, setReplaceCandidate] = useState<string | null>(null);
// Default the select to the first connection once loaded.
const effectiveId = selectedId || connections[0]?.id || '';
async function start(forceReplace: boolean) {
if (!effectiveId) return;
setSubmitting(true);
setErrMsg(null);
setReplaceCandidate(null);
try {
const res = await fetch(`/api/local/tasks/${taskId}/console/session`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connection_id: effectiveId,
...(forceReplace ? { force_replace: true } : {}),
}),
});
if (res.ok) {
onStarted();
return;
}
let code = `HTTP ${res.status}`;
try {
const j = (await res.json()) as { error?: string };
if (j?.error) code = j.error;
} catch {
// non-JSON body; keep HTTP status as the code
}
if (code === 'connection_change_requires_force') {
setReplaceCandidate(effectiveId);
setErrMsg({
msg: '別の接続のセッションが既に存在します。置き換えて開始できます。',
hardStop: false,
});
return;
}
setErrMsg(describeSessionError(code));
} catch (e) {
setErrMsg({ msg: e instanceof Error ? e.message : String(e), hardStop: false });
} finally {
setSubmitting(false);
}
}
if (data?.sshDisabled) {
return (
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
<div className="max-w-md text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-4 leading-relaxed">
SSH <code className="font-mono">config.yaml</code> {' '}
<code className="font-mono">ssh.enabled: true</code>
</div>
</div>
);
}
return (
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
<div className="w-full max-w-md space-y-3">
<div>
<h3 className="text-sm font-semibold text-slate-100">SSH </h3>
<p className="text-2xs text-slate-400 mt-0.5">
AI
</p>
</div>
{isLoading && <div className="text-xs text-slate-400">Loading</div>}
{error && <div className="text-xs text-red-400">: {String(error)}</div>}
{!isLoading && connections.length === 0 ? (
<div className="text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-3 leading-relaxed">
SSH Settings SSH Connections /grant
</div>
) : (
<>
<select
value={effectiveId}
onChange={(e) => { setSelectedId(e.target.value); setErrMsg(null); setReplaceCandidate(null); }}
disabled={submitting}
className="w-full text-xs px-2 py-1.5 bg-surface border border-hairline rounded text-slate-100 disabled:opacity-50"
>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.label} {c.username}@{c.host}:{c.port}
</option>
))}
</select>
<button
type="button"
onClick={() => start(false)}
disabled={submitting || !effectiveId}
className="w-full px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
>
{submitting ? '開始中…' : 'セッション開始'}
</button>
{replaceCandidate && (
<button
type="button"
onClick={() => start(true)}
disabled={submitting}
className="w-full px-3 h-8 text-xs font-semibold border border-amber-400/50 text-amber-300 rounded-md hover:bg-amber-500/15 disabled:opacity-50"
>
</button>
)}
</>
)}
{errMsg && (
<div
className={
errMsg.hardStop
? 'text-xs text-red-200 bg-red-500/20 border border-red-500/50 rounded-md p-3 leading-relaxed'
: 'text-xs text-amber-200 bg-amber-500/10 border border-amber-500/30 rounded-md p-3 leading-relaxed'
}
>
{errMsg.msg}
</div>
)}
</div>
{compactMode && <MobileKeyboardBar session={session} />}
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { type Axis, lockAxis, canScrollFurther, resist, shouldCommit, neighborIndex } from '../../lib/tab-swipe';
interface SwipeableTabsProps<T extends string> {
/** Visible tab ids, in order. */
tabs: T[];
activeTab: T;
onTabChange: (tab: T) => void;
/** Swiping right past the threshold while on the FIRST tab (e.g. close). */
onSwipeBackFromFirst?: () => void;
/**
* Render a tab's content. `preview` is true for the neighbor mounted DURING a
* drag — return a lightweight placeholder for side-effecting tabs (e.g. a
* noVNC iframe or SSH WebSocket) so a partial/cancelled swipe doesn't open
* connections; the real content mounts once the swipe commits (preview=false).
*/
renderTab: (tab: T, preview: boolean) => ReactNode;
}
type Mode = 'idle' | 'pending' | 'vert' | 'scroll' | 'drag';
/**
* Horizontal swipe-between-tabs with finger-following content and a peeking
* neighbor (iOS-style). Native horizontal scrolling INSIDE the content (e.g. a
* wide <pre>) wins until it reaches its edge, so scrolling a code block no
* longer steals the tab gesture. The active layer's transform is mutated
* imperatively during the drag so the (heavy) tab panels don't re-render per
* frame; the neighbor is mounted only while a drag is in progress.
*/
export function SwipeableTabs<T extends string>({
tabs,
activeTab,
onTabChange,
onSwipeBackFromFirst,
renderTab,
}: SwipeableTabsProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLDivElement>(null);
const peekRef = useRef<HTMLDivElement>(null);
// The neighbor tab to mount while dragging ('left' = next on the right edge,
// 'right' = prev on the left edge). null = no drag in progress.
const [peek, setPeek] = useState<{ dir: 'left' | 'right'; tab: T } | null>(null);
const g = useRef({
startX: 0,
startY: 0,
axis: null as Axis | null,
mode: 'idle' as Mode,
scroller: null as Element | null,
dir: null as 'left' | 'right' | null,
width: 0,
dragX: 0,
rawDx: 0,
});
// Latest props for the native (re-attached) handlers.
const propsRef = useRef({ tabs, activeTab, onTabChange, onSwipeBackFromFirst });
propsRef.current = { tabs, activeTab, onTabChange, onSwipeBackFromFirst };
const baseOffset = (dir: 'left' | 'right') => (dir === 'left' ? '100%' : '-100%');
const paint = (dx: number, animate: boolean) => {
const a = activeRef.current;
const p = peekRef.current;
const t = animate ? 'transform 0.24s cubic-bezier(0.22,0.61,0.36,1)' : 'none';
if (a) {
a.style.transition = t;
a.style.transform = `translate3d(${dx}px,0,0)`;
}
if (p && g.current.dir) {
p.style.transition = t;
p.style.transform = `translate3d(calc(${baseOffset(g.current.dir)} + ${dx}px),0,0)`;
}
};
// Find the nearest horizontally-scrollable ancestor between `el` and the
// container (inclusive of el, exclusive of container).
const findScroller = (el: Element | null): Element | null => {
let node = el;
const stop = containerRef.current;
while (node && node !== stop) {
if (node instanceof HTMLElement) {
const ox = getComputedStyle(node).overflowX;
if ((ox === 'auto' || ox === 'scroll') && node.scrollWidth - node.clientWidth > 1) {
return node;
}
}
node = node.parentElement;
}
return null;
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const reset = () => {
g.current.mode = 'idle';
g.current.axis = null;
g.current.scroller = null;
g.current.dir = null;
g.current.dragX = 0;
};
const onStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
g.current.mode = 'idle';
// Don't fight cursor placement, button presses, or link taps: ignore
// gestures that begin on a form control / button / anchor / editable.
const target = e.target as Element | null;
if (target?.closest('input, textarea, select, button, a, [contenteditable="true"], [data-no-swipe]')) {
return;
}
const t = e.touches[0];
g.current.startX = t.clientX;
g.current.startY = t.clientY;
g.current.axis = null;
g.current.mode = 'pending';
g.current.scroller = findScroller(e.target as Element | null);
g.current.width = el.clientWidth;
g.current.dir = null;
g.current.dragX = 0;
};
const onMove = (e: TouchEvent) => {
if (g.current.mode === 'idle' || g.current.mode === 'vert' || g.current.mode === 'scroll') return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - g.current.startX;
const dy = t.clientY - g.current.startY;
if (g.current.axis === null) {
const a = lockAxis(dx, dy);
if (!a) return;
g.current.axis = a;
if (a === 'v') {
g.current.mode = 'vert';
return;
}
}
if (g.current.axis !== 'h') return;
// Only decide to yield to native scroll BEFORE a drag has started. Once
// dragging, reversing direction must not hand the gesture back to the
// scroller mid-drag (that would leave the panels painted off-center).
const sc = g.current.scroller;
if (g.current.mode !== 'drag' && sc && canScrollFurther(sc as HTMLElement, dx)) {
g.current.mode = 'scroll';
return;
}
e.preventDefault(); // own the horizontal gesture
const { tabs: ts, activeTab: at } = propsRef.current;
const idx = ts.indexOf(at);
const ni = neighborIndex(idx, dx, ts.length);
const hasNeighbor = ni !== null;
const dir: 'left' | 'right' = dx < 0 ? 'left' : 'right';
if (g.current.mode !== 'drag') {
g.current.mode = 'drag';
}
if (g.current.dir !== dir) {
g.current.dir = dir;
// Mount the neighbor (or null at an edge so the layer just rubber-bands).
setPeek(hasNeighbor ? { dir, tab: ts[ni] } : null);
}
g.current.rawDx = dx;
g.current.dragX = resist(dx, hasNeighbor, g.current.width);
paint(g.current.dragX, false);
};
const finish = () => {
if (g.current.mode !== 'drag') {
reset();
return;
}
const { tabs: ts, activeTab: at, onTabChange: change, onSwipeBackFromFirst: back } = propsRef.current;
const idx = ts.indexOf(at);
const dragX = g.current.dragX;
const width = g.current.width;
const ni = neighborIndex(idx, dragX, ts.length);
const dir = g.current.dir;
// First tab + rightward swipe past threshold → "back" (close), no neighbor.
// Use the RAW (un-resisted) movement: dragX is rubber-banded to ~12% width
// at an edge, so it could never reach the threshold otherwise.
if (ni === null && dir === 'right' && idx === 0 && back && Math.abs(g.current.rawDx) >= Math.max(60, width * 0.22)) {
paint(0, true);
window.setTimeout(() => { reset(); setPeek(null); }, 240);
back();
return;
}
if (ni !== null && shouldCommit(dragX, width, true)) {
const target = ts[ni];
// Slide fully, then commit the tab change and snap back to center.
paint(dir === 'left' ? -width : width, true);
window.setTimeout(() => {
change(target);
setPeek(null);
g.current.dir = null;
requestAnimationFrame(() => paint(0, false));
reset();
}, 240);
} else {
paint(0, true);
window.setTimeout(() => { setPeek(null); reset(); }, 240);
}
};
// touchcancel = the OS/browser aborted the gesture (notification, multi-touch,
// interruption) — NOT a user commit. Snap back without changing tabs.
const cancel = () => {
if (g.current.mode === 'drag') {
paint(0, true);
window.setTimeout(() => { setPeek(null); reset(); }, 240);
} else {
reset();
}
};
el.addEventListener('touchstart', onStart, { passive: true });
el.addEventListener('touchmove', onMove, { passive: false });
el.addEventListener('touchend', finish, { passive: true });
el.addEventListener('touchcancel', cancel, { passive: true });
return () => {
el.removeEventListener('touchstart', onStart);
el.removeEventListener('touchmove', onMove);
el.removeEventListener('touchend', finish);
el.removeEventListener('touchcancel', cancel);
};
}, []);
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden">
<div ref={activeRef} className="absolute inset-0 will-change-transform">
{renderTab(activeTab, false)}
</div>
{peek && (
<div
ref={peekRef}
className="absolute inset-0 will-change-transform"
style={{ transform: `translate3d(${baseOffset(peek.dir)},0,0)` }}
>
{renderTab(peek.tab, true)}
</div>
)}
</div>
);
}
+30
View File
@@ -17,6 +17,13 @@ export interface ConsoleSessionApi {
send(input: string): void;
sendResize(cols: number, rows: number): void;
close(): void;
/**
* Force an immediate (re)connect attempt, resetting the backoff timer.
* Used after the user opens a session via REST so the terminal attaches
* without waiting for the next scheduled retry / 5s status poll. Safe to
* call at any time; the normal auto-reconnect keeps running afterwards.
*/
reconnectNow(): void;
}
/**
@@ -35,6 +42,10 @@ export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
const outputListeners = useRef(new Set<(d: Uint8Array) => void>());
const noticeListeners = useRef(new Set<(m: any) => void>());
const lastAttachRef = useRef<{ canWrite: boolean; cols: number; rows: number } | null>(null);
// Populated by the connection effect with a callback that forces an
// immediate reconnect (resetting backoff). Held in a ref so the stable
// `reconnectNow` returned below can delegate to the live closure.
const reconnectNowRef = useRef<(() => void) | null>(null);
useEffect(() => {
let cancelled = false;
@@ -100,10 +111,26 @@ export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
};
};
// Expose an on-demand reconnect: cancel any pending backoff retry, reset
// the delay, drop the current socket and reconnect immediately. The
// existing ws.onclose auto-reconnect still fires for organic disconnects.
reconnectNowRef.current = () => {
if (cancelled) return;
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
retryDelayMs = 1000;
const cur = wsRef.current;
if (cur && (cur.readyState === cur.OPEN || cur.readyState === cur.CONNECTING)) {
// Already (re)connecting/connected — nothing to force.
return;
}
connect();
};
connect();
return () => {
cancelled = true;
reconnectNowRef.current = null;
if (retryTimer) clearTimeout(retryTimer);
try { wsRef.current?.close(); } catch {}
};
@@ -134,5 +161,8 @@ export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
close() {
try { wsRef.current?.close(); } catch {}
},
reconnectNow() {
reconnectNowRef.current?.();
},
};
}
-60
View File
@@ -1,60 +0,0 @@
import { useRef, type TouchEvent } from 'react';
interface UseSwipeNavOptions {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
/** Minimum horizontal distance (px) to count as a swipe. Default 60. */
threshold?: number;
/** Maximum vertical drift (px) before the gesture is treated as a scroll. Default 40. */
verticalTolerance?: number;
}
/**
* Lightweight horizontal swipe handler. Pure DOM touch events, no deps.
*
* Usage:
* const swipe = useSwipeNav({ onSwipeLeft: next, onSwipeRight: prev });
* <div {...swipe}>...</div>
*
* Behavior:
* - Touches starting on form controls (input / textarea / select / button /
* anchor / contenteditable) are ignored so we don't fight cursor placement
* or button presses.
* - Gestures with vertical drift > verticalTolerance are treated as
* scrolls and ignored, so vertical scroll inside panels still works.
* - Horizontal distance must exceed threshold to trigger a callback.
*/
export function useSwipeNav({
onSwipeLeft,
onSwipeRight,
threshold = 60,
verticalTolerance = 40,
}: UseSwipeNavOptions) {
const start = useRef<{ x: number; y: number; ignored: boolean } | null>(null);
const onTouchStart = (e: TouchEvent<HTMLElement>) => {
const touch = e.touches[0];
if (!touch) return;
const target = e.target as HTMLElement | null;
const ignored = !!target?.closest(
'input, textarea, select, button, a, [contenteditable="true"], [data-no-swipe]',
);
start.current = { x: touch.clientX, y: touch.clientY, ignored };
};
const onTouchEnd = (e: TouchEvent<HTMLElement>) => {
const s = start.current;
start.current = null;
if (!s || s.ignored) return;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - s.x;
const dy = touch.clientY - s.y;
if (Math.abs(dy) > verticalTolerance) return;
if (Math.abs(dx) < threshold) return;
if (dx < 0) onSwipeLeft?.();
else onSwipeRight?.();
};
return { onTouchStart, onTouchEnd };
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { lockAxis, canScrollFurther, resist, shouldCommit, neighborIndex } from './tab-swipe';
describe('lockAxis', () => {
it('stays null until past the threshold', () => {
expect(lockAxis(3, 2)).toBe(null);
expect(lockAxis(7, 7)).toBe(null);
});
it('locks to the dominant axis past threshold', () => {
expect(lockAxis(20, 4)).toBe('h');
expect(lockAxis(4, 20)).toBe('v');
expect(lockAxis(12, 12)).toBe('h'); // tie → horizontal
});
});
describe('canScrollFurther', () => {
const el = { scrollLeft: 50, scrollWidth: 300, clientWidth: 100 }; // max=200
it('true when there is room in the finger direction', () => {
expect(canScrollFurther(el, +1)).toBe(true); // moving right, scrollLeft>0
expect(canScrollFurther(el, -1)).toBe(true); // moving left, room on right
});
it('false at the respective edge', () => {
expect(canScrollFurther({ ...el, scrollLeft: 0 }, +1)).toBe(false);
expect(canScrollFurther({ ...el, scrollLeft: 200 }, -1)).toBe(false);
});
it('false when not scrollable', () => {
expect(canScrollFurther({ scrollLeft: 0, scrollWidth: 100, clientWidth: 100 }, -1)).toBe(false);
});
});
describe('resist', () => {
it('is 1:1 when a neighbor exists', () => {
expect(resist(80, true, 360)).toBe(80);
});
it('rubber-bands (smaller, same sign) with no neighbor', () => {
const r = resist(120, false, 360);
expect(Math.sign(r)).toBe(1);
expect(Math.abs(r)).toBeLessThan(120);
});
});
describe('shouldCommit', () => {
it('commits past max(60, 22% width) with a neighbor', () => {
expect(shouldCommit(-100, 360, true)).toBe(true); // 22%*360=79.2, 100>79
expect(shouldCommit(-70, 360, true)).toBe(false); // 70<79
expect(shouldCommit(-70, 200, true)).toBe(true); // max(60,44)=60, 70>60
});
it('never commits without a neighbor', () => {
expect(shouldCommit(-300, 360, false)).toBe(false);
});
});
describe('neighborIndex', () => {
it('drag left → next, drag right → prev', () => {
expect(neighborIndex(1, -10, 4)).toBe(2);
expect(neighborIndex(1, 10, 4)).toBe(0);
});
it('null at the edges', () => {
expect(neighborIndex(3, -10, 4)).toBe(null);
expect(neighborIndex(0, 10, 4)).toBe(null);
expect(neighborIndex(1, 0, 4)).toBe(null);
});
});
+66
View File
@@ -0,0 +1,66 @@
// Pure decision helpers for the mobile swipe-between-tabs gesture. The DOM
// wiring lives in components/mobile/SwipeableTabs.tsx; this module is the
// testable core (axis lock, native-scroll conflict, rubber-band, commit).
export type Axis = 'h' | 'v';
/**
* Lock the gesture axis once the finger has moved past `threshold` on either
* axis. Returns null while the movement is still too small to classify.
*/
export function lockAxis(dx: number, dy: number, threshold = 8): Axis | null {
const ax = Math.abs(dx);
const ay = Math.abs(dy);
if (ax < threshold && ay < threshold) return null;
return ax >= ay ? 'h' : 'v';
}
/**
* Whether a horizontally-scrollable element can still scroll in the direction
* the FINGER is moving (`dirX`): a finger moving right (dirX > 0) reveals the
* left side, i.e. needs scrollLeft > 0; moving left needs room on the right.
* When it can, the native scroll should win over the tab swipe.
*/
export function canScrollFurther(
el: { scrollLeft: number; scrollWidth: number; clientWidth: number },
dirX: number,
): boolean {
if (dirX === 0) return false;
const maxScroll = el.scrollWidth - el.clientWidth;
if (maxScroll <= 1) return false; // not actually scrollable
return dirX > 0 ? el.scrollLeft > 1 : el.scrollLeft < maxScroll - 1;
}
/**
* Rubber-band the drag offset. When there IS a neighbor in the drag direction
* the offset is 1:1; when there is none (first/last tab) it resists so the edge
* feels bounded instead of dragging into empty space.
*/
export function resist(dx: number, hasNeighbor: boolean, width: number): number {
if (hasNeighbor) return dx;
const w = Math.max(1, width);
const sign = Math.sign(dx);
// Asymptotic resistance capped at ~12% of width.
return sign * w * 0.12 * (1 - Math.exp(-Math.abs(dx) / w));
}
/**
* Commit to the neighbor tab when the drag passed the threshold (the larger of
* 60px or 22% of the container width) AND a neighbor exists in that direction.
*/
export function shouldCommit(dragX: number, width: number, hasNeighbor: boolean): boolean {
if (!hasNeighbor) return false;
const threshold = Math.max(60, width * 0.22);
return Math.abs(dragX) >= threshold;
}
/**
* Resolve the neighbor index for a drag. dragX < 0 (finger moved left) goes to
* the NEXT tab; dragX > 0 goes to the PREVIOUS. Returns null when there is no
* neighbor in that direction.
*/
export function neighborIndex(active: number, dragX: number, count: number): number | null {
if (dragX < 0) return active < count - 1 ? active + 1 : null;
if (dragX > 0) return active > 0 ? active - 1 : null;
return null;
}