This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user