This commit is contained in:
+39
-57
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user