From 38bd874366cd045b4e0a59b4dcbb0d67ea0b37dd Mon Sep 17 00:00:00 2001 From: oss-sync Date: Mon, 8 Jun 2026 03:58:10 +0000 Subject: [PATCH] sync: update from private repo (ea88916) --- docs/tools/ssh-console-tools.md | 2 + pieces/ssh-console.yaml | 13 +- pieces/ssh-ops.yaml | 10 +- src/bridge/console-session-api.test.ts | 313 ++++++++++++ src/bridge/console-ws-api.ts | 147 ++++++ src/bridge/server.ts | 27 +- src/engine/tools/ssh-console.ts | 510 ++++++++++++------- ui/src/App.tsx | 96 ++-- ui/src/components/detail/tabs/ConsoleTab.tsx | 216 +++++++- ui/src/components/mobile/SwipeableTabs.tsx | 250 +++++++++ ui/src/hooks/useConsoleSession.ts | 30 ++ ui/src/hooks/useSwipeNav.ts | 60 --- ui/src/lib/tab-swipe.test.ts | 63 +++ ui/src/lib/tab-swipe.ts | 66 +++ 14 files changed, 1500 insertions(+), 303 deletions(-) create mode 100644 src/bridge/console-session-api.test.ts create mode 100644 ui/src/components/mobile/SwipeableTabs.tsx delete mode 100644 ui/src/hooks/useSwipeNav.ts create mode 100644 ui/src/lib/tab-swipe.test.ts create mode 100644 ui/src/lib/tab-swipe.ts diff --git a/docs/tools/ssh-console-tools.md b/docs/tools/ssh-console-tools.md index ad641c2..1a513cd 100644 --- a/docs/tools/ssh-console-tools.md +++ b/docs/tools/ssh-console-tools.md @@ -4,6 +4,8 @@ AI と人間が共有する SSH PTY セッションを操作する 3 ツール 単発コマンドだけなら **`SshExec`** (ssh-ops piece) のほうが軽い。本ツール群は対話的シェル + AI が画面を見続ける用途に最適化されている。 +> **ユーザーが先にセッションを開いている場合がある**: タスク詳細の **Console タブ**から、ユーザーが接続を選んで自分でセッションを起動できる。その場合 `SshConsoleEnsure` は既存セッションをそのまま再利用する (`connection_id` を省略すれば active session が採用される)。「まず console を開く」操作を AI 側でやり直す必要はない。 + ## 典型的な flow (まずこれを真似る) ```js diff --git a/pieces/ssh-console.yaml b/pieces/ssh-console.yaml index 47e4ade..b823fe5 100644 --- a/pieces/ssh-console.yaml +++ b/pieces/ssh-console.yaml @@ -29,6 +29,10 @@ movements: でも同じ doc が返る。SshListConnections は ReadToolDoc({name: "SshListConnections"})。 ## 標準 flow + 0. ユーザーが Console タブから既にセッションを開いていることがある。その場合は + connection_id を省略して SshConsoleSnapshot({}) で現在の画面を確認し、そのまま + SshConsoleSend で続ける (改めて Ensure し直す必要はない)。"no live session" が + 返ってきたら下の手順で自分で開く 1. タスク本文を読み、どのリモートホストでどんな作業をするか把握する 2. connection_id (UUID) がタスク本文に無ければ SshListConnections({}) で発見する - **必ず id フィールドを使う**。label ("terminal" など) や host ("192.168.1.x" など) を connection_id として渡してはいけない @@ -60,6 +64,13 @@ movements: output/ または input/ 配下を使う。詳細・エラーコードは ReadToolDoc({name: "SshUpload"}) / ReadToolDoc({name: "SshDownload"}) + ## 調べ物 (Web 検索) + - 不明なコマンド・オプション、エラーメッセージ、設定手順が出てきたら WebSearch で調べてよい。 + ヒットしたページや公式 docs の本文は WebFetch で読む。JS レンダリングや操作が要るページは BrowseWeb。 + - インストーラ / 設定テンプレート / tarball を URL から取得して配信したいときは DownloadFile で + workspace の output/ または input/ に落とし、SshUpload でリモートへ送る + - 検索結果を鵜呑みにしてリモートで破壊的コマンドを実行しない。出典を確認し、不可逆操作は user に確認する + ## 注意 - shell 状態 (cd / env / foreground プロセス) はタスク内で維持される。毎ターン cd し直す必要なし - 機密値はコマンド文字列に直接書かない (audit log に hash で残る) @@ -70,7 +81,7 @@ movements: - 完了: complete({status: "success", result: "..."}) - 中断: complete({status: "aborted", abort_reason: "..."}) - 確認待ち: complete({status: "needs_user_input", missing_info: "..."}) - allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleRun, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, Read, Write, Bash, Glob, Grep] + allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleRun, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, WebSearch, WebFetch, DownloadFile, BrowseWeb, Read, Write, Bash, Glob, Grep] allowed_ssh_connections: ['*'] default_next: COMPLETE rules: [] diff --git a/pieces/ssh-ops.yaml b/pieces/ssh-ops.yaml index a465fdb..ef68cfa 100644 --- a/pieces/ssh-ops.yaml +++ b/pieces/ssh-ops.yaml @@ -63,6 +63,14 @@ movements: - `connect_timeout` / `auth_failed` 等の一時失敗: 同じ command を最大 2 回まで再試行。 それ以上は `complete({status: "aborted", abort_reason: "..."})` + ## 調べ物 (Web 検索) + + - 不明なコマンド・オプション、エラーメッセージ、設定手順が出てきたら WebSearch で調べてよい。 + ヒットしたページや公式 docs の本文は WebFetch、JS レンダリングや操作が要るページは BrowseWeb で読む + - 設定テンプレート / インストーラ / tarball を URL から取得して配信したい場合は DownloadFile で + output/ または input/ に落とし、SshUpload でリモートへ送る + - 検索結果を鵜呑みにしてリモートで破壊的・不可逆なコマンドを実行しない。出典を確認し、不安なら停止して user に確認する + ## 成果物 ops の結果は output/report.md にまとめる。**機密値は記録しない**: @@ -77,7 +85,7 @@ movements: - **次の verify へ**: `transition({next_step: "verify"})` - **必要情報不足で停止**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **致命的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - allowed_tools: [SshExec, SshUpload, SshDownload, SshListConnections, Read, Write, Bash, Glob, Grep] + allowed_tools: [SshExec, SshUpload, SshDownload, SshListConnections, WebSearch, WebFetch, DownloadFile, BrowseWeb, Read, Write, Bash, Glob, Grep] allowed_ssh_connections: ['*'] default_next: verify rules: diff --git a/src/bridge/console-session-api.test.ts b/src/bridge/console-session-api.test.ts new file mode 100644 index 0000000..758bdd1 --- /dev/null +++ b/src/bridge/console-session-api.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for POST /api/local/tasks/:taskId/console/session — the + * user-initiated SSH console session-open endpoint (Task 2). + * + * Strategy: mirror the hermetic stub-subsystem approach from + * ssh-console.test.ts. We do NOT dial real SSH. The shell-open collaborator + * (`openShellChannel`) is faked to return a channel/client/fingerprint, and a + * real `SessionRegistry` is used so we can assert `registry.get(taskId)` + * actually returns a session after a successful open. The access gate + * (preflight + accessResolver + host-key check) runs for real inside the + * shared `openConsoleSession` core the endpoint calls — the test only fakes + * the network dial, not the gate. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { createConsoleSessionRouter } from './console-ws-api.js'; +import { preflight, type SshSubsystem } from '../engine/tools/ssh.js'; +import { SessionRegistry } from '../ssh/console-registry.js'; +import type { SimpleTask, SimpleUser } from './console-ws-api.js'; + +function mkConn(overrides: Partial<{ hostKeyVerifiedAt: string | null; enabled: boolean }> = {}) { + return { + id: 'conn-1', + ownerId: 'owner-1', + label: 'test', + host: 'localhost', + port: 22, + username: 'me', + privateKeyEnc: Buffer.alloc(0), + passphraseEnc: null, + keyVersion: 1, + keyFingerprint: 'fp-key', + hostKeyType: 'ssh-ed25519', + hostKeyB64: 'aaa', + hostKeyFingerprint: 'fp', + hostKeyRecordedAt: '2026-01-01', + hostKeyVerifiedAt: overrides.hostKeyVerifiedAt === undefined ? '2026-01-01' : overrides.hostKeyVerifiedAt, + hostKeyPending: false, + hostKeyPendingB64: null, + hostKeyPendingFingerprint: null, + hostKeyPendingToken: null, + hostKeyPendingSource: null, + commandDenyPatterns: null, + commandAllowPatterns: null, + remotePathPrefix: '/', + allowRemoteUnrestricted: true, + allowPrivateAddresses: true, + enabled: overrides.enabled === undefined ? true : overrides.enabled, + disabledByAdmin: false, + disabledByAdminReason: null, + disabledByAdminAt: null, + disabledByAdminUserId: null, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }; +} + +interface Harness { + sub: SshSubsystem; + registry: SessionRegistry; + openShellChannel: ReturnType; + closeForTaskSpy: ReturnType; +} + +function mkSub(opts: { + conn?: ReturnType; + accessAllowed?: boolean; + accessReason?: string; + isAdmin?: boolean; +} = {}): Harness { + const conn = opts.conn ?? mkConn(); + const audit = { + beginAndComplete: vi.fn().mockReturnValue(1), + begin: vi.fn().mockReturnValue(1), + complete: vi.fn(), + listAuditRows: vi.fn(), + pruneOlderThan: vi.fn(), + promotePendingToAborted: vi.fn(), + }; + + const registry = new SessionRegistry({ + idleTimeoutMs: 60_000, + maxSessionDurationMs: 600_000, + maxSessionsPerConnection: 3, + }); + // Wrap closeForTask so we can assert force-replace behavior without + // depending on a real channel teardown. + const closeForTaskSpy = vi.fn(registry.closeForTask.bind(registry)); + (registry as any).closeForTask = closeForTaskSpy; + + const connectionRepo = { + resolveConnection: vi.fn().mockReturnValue(conn), + }; + const abuseRepo = { + isLocked: vi.fn().mockReturnValue({ locked: false }), + checkAndRecordFailure: vi.fn(), + recordSuccess: vi.fn(), + }; + const accessResolver = { + resolveAccess: vi.fn().mockReturnValue( + opts.accessAllowed === false + ? { allowed: false, reason: opts.accessReason ?? 'no_grant' } + : { allowed: true }, + ), + }; + const channel = { write: vi.fn(), end: vi.fn(), setWindow: vi.fn(), on: vi.fn() }; + const client = { end: vi.fn(), on: vi.fn() }; + const openShellChannel = vi.fn().mockResolvedValue({ + channel, + client, + hostFingerprint: 'SHA256:fake', + }); + + const sub = { + connectionRepo, + auditRepo: audit, + abuseRepo, + accessResolver, + sessionRegistry: registry, + openShellChannel, + getUserAccess: () => ({ isAdmin: opts.isAdmin ?? false, orgIds: [] }), + decryptKeyMaterial: () => Buffer.alloc(0), + decryptPassphrase: () => null, + sshExec: vi.fn(), + sshUpload: vi.fn(), + sshDownload: vi.fn(), + maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => {}, exit: () => {} } as SshSubsystem['maintenance'], + config: { + enabled: true, + allowPrivateAddresses: true, + callTimeoutSeconds: 30, + maxOutputBytes: 1024, + maxUploadSizeMb: 10, + maxDownloadSizeMb: 10, + auditRetentionDays: 90, + adminBypassesGrants: true, + abuseWindowMinutes: 10, + abuseFailureThreshold: 5, + abuseLockMinutes: 30, + console: { + enabled: true, + idleTimeoutSeconds: 60, + maxSessionDurationSeconds: 600, + scrollbackBytes: 4096, + maxSessionsPerConnection: 3, + maxInputBytesPerSend: 1024, + autoInjectScreenLines: 24, + defaultCols: 80, + defaultRows: 24, + }, + }, + } as unknown as SshSubsystem; + + return { sub, registry, openShellChannel, closeForTaskSpy }; +} + +const OWNER: SimpleUser = { id: 'owner-1', role: 'user' }; + +function mkTask(overrides: Partial = {}): SimpleTask { + return { + id: '1', + ownerId: 'owner-1', + visibility: 'private', + pieceName: 'ssh-console', + ...overrides, + }; +} + +function buildApp(opts: { + sub: SshSubsystem; + user?: SimpleUser | null; + resolveTask?: (id: string, user: SimpleUser) => Promise; +}) { + const app = express(); + const user = opts.user === undefined ? OWNER : opts.user; + if (user) { + app.use((req, _res, next) => { (req as any).user = user; next(); }); + } + app.use( + '/api', + express.json(), + createConsoleSessionRouter({ + sub: opts.sub, + preflight, + requireAuth: (_req: any, _res: any, next: any) => next(), + resolveTask: opts.resolveTask ?? (async () => mkTask()), + }), + ); + return app; +} + +describe('POST /api/local/tasks/:taskId/console/session', () => { + beforeEach(() => vi.clearAllMocks()); + + it('connection owner → 200 and a session is registered for the task', async () => { + const h = mkSub(); + const app = buildApp({ sub: h.sub }); + const res = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-1' }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.connection_id).toBe('conn-1'); + expect(h.openShellChannel).toHaveBeenCalledTimes(1); + // The real registry now holds a live session keyed by the task id. + expect(h.registry.get('1')).toBeTruthy(); + expect(h.registry.get('1')!.connectionId).toBe('conn-1'); + }); + + it('non-owner without grant → 403 no_grant', async () => { + const h = mkSub({ accessAllowed: false, accessReason: 'no_grant' }); + const app = buildApp({ + sub: h.sub, + user: { id: 'someone-else', role: 'user' }, + }); + const res = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-1' }); + expect(res.status).toBe(403); + expect(res.body.error).toBe('no_grant'); + expect(h.openShellChannel).not.toHaveBeenCalled(); + expect(h.registry.get('1')).toBeNull(); + }); + + it('host key not verified → 409 host_key_not_verified', async () => { + const h = mkSub({ conn: mkConn({ hostKeyVerifiedAt: null }) }); + const app = buildApp({ sub: h.sub }); + const res = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-1' }); + expect(res.status).toBe(409); + expect(res.body.error).toBe('host_key_not_verified'); + expect(h.openShellChannel).not.toHaveBeenCalled(); + }); + + it('task not visible to the user → 404 task_not_found', async () => { + const h = mkSub(); + const app = buildApp({ sub: h.sub, resolveTask: async () => null }); + const res = await request(app) + .post('/api/local/tasks/999/console/session') + .send({ connection_id: 'conn-1' }); + expect(res.status).toBe(404); + expect(res.body.error).toBe('task_not_found'); + expect(h.openShellChannel).not.toHaveBeenCalled(); + }); + + it('already-active same connection → 200 already_active:true (no re-dial)', async () => { + const h = mkSub(); + const app = buildApp({ sub: h.sub }); + // First open establishes the session. + const first = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-1' }); + expect(first.status).toBe(200); + expect(h.openShellChannel).toHaveBeenCalledTimes(1); + // Second open on the same connection reuses it. + const second = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-1' }); + expect(second.status).toBe(200); + expect(second.body.ok).toBe(true); + expect(second.body.already_active).toBe(true); + // Still only one dial total. + expect(h.openShellChannel).toHaveBeenCalledTimes(1); + }); + + it('already-active different connection without force_replace → 409, with it → 200', async () => { + const h = mkSub(); + // conn-1 resolves first; for the swap, return conn-2. + const conn2 = mkConn(); + (conn2 as any).id = 'conn-2'; + const app = buildApp({ sub: h.sub }); + + // Open conn-1. + const first = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-1' }); + expect(first.status).toBe(200); + expect(h.openShellChannel).toHaveBeenCalledTimes(1); + + // conn-2 now resolves for the swap attempts. + (h.sub.connectionRepo.resolveConnection as any).mockReturnValue(conn2); + + // Swap without force → 409 connection_change_requires_force. + const noForce = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-2' }); + expect(noForce.status).toBe(409); + expect(noForce.body.error).toBe('connection_change_requires_force'); + expect(h.openShellChannel).toHaveBeenCalledTimes(1); // no new dial + + // Swap with force → 200, old closed, new dialed. + const force = await request(app) + .post('/api/local/tasks/1/console/session') + .send({ connection_id: 'conn-2', force_replace: true }); + expect(force.status).toBe(200); + expect(force.body.ok).toBe(true); + expect(h.closeForTaskSpy).toHaveBeenCalledWith('1', 'connection_change'); + expect(h.openShellChannel).toHaveBeenCalledTimes(2); + expect(h.registry.get('1')!.connectionId).toBe('conn-2'); + }); + + it('missing connection_id → 400', async () => { + const h = mkSub(); + const app = buildApp({ sub: h.sub }); + const res = await request(app) + .post('/api/local/tasks/1/console/session') + .send({}); + expect(res.status).toBe(400); + expect(res.body.error).toBe('missing_connection_id'); + }); +}); diff --git a/src/bridge/console-ws-api.ts b/src/bridge/console-ws-api.ts index 2b7604b..0fe39a8 100644 --- a/src/bridge/console-ws-api.ts +++ b/src/bridge/console-ws-api.ts @@ -7,6 +7,8 @@ import type { SessionRegistry } from '../ssh/console-registry.js'; import type { ConsoleSession } from '../ssh/console-session.js'; import type { AttachMessage, ServerTextMessage } from '../ssh/console-protocol.js'; import { checkConsoleInput } from '../ssh/console-deny-check.js'; +import type { OpenConsoleDeps, OpenConsoleResult } from '../engine/tools/ssh-console.js'; +import { openConsoleSession } from '../engine/tools/ssh-console.js'; export interface SimpleUser { id: string; role: 'admin' | 'user' | string } export interface SimpleTask { id: string; ownerId: string; visibility: string; pieceName: string } @@ -289,3 +291,148 @@ export function createConsoleStatusRouter(deps: { ); return r; } + +/** + * Map an `OpenConsoleResult.error` code to an HTTP status. The endpoint + * surfaces the same structured `error` code string in the JSON body so the + * UI can map it to a localized message. Unknown codes default to 400 (caller + * error) except the internal failure codes which default to 500. + */ +function statusForOpenError(error: string | undefined): number { + switch (error) { + case 'connection_not_found': + return 404; + case 'no_grant': + return 403; + case 'host_key_not_verified': + case 'host_key_mismatch': + case 'connection_change_requires_force': + case 'abuse_locked': + case 'connection_disabled': + return 409; + case 'decrypt_failed': + case 'open_shell_failed': + return 500; + // missing_connection_id / missing_task_context / no_user_context / + // piece_not_configured / piece_not_allowed / preflight_denied and any + // other unexpected code → 400 (the request was malformed or denied at a + // layer that maps cleanly onto a bad-request response). + default: + return 400; + } +} + +/** + * The preflight helper (`preflight` from engine/tools/ssh.ts) returns a flat + * `preflight_denied` for several distinct denials (no grant, abuse lock, + * disabled connection, connection not found). The REST contract wants the + * specific codes, so the handler re-derives them from the human-readable + * message that `openConsoleSession` mirrors into `result.message`. This is a + * best-effort refinement layered ON TOP of the real gate — the gate itself + * (inside openConsoleSession/preflight) is authoritative and unchanged; we + * never widen access here, only narrow a generic 400 into a more specific + * 4xx for the UI. + */ +function refineErrorCode(result: OpenConsoleResult): string { + const code = result.error ?? 'unknown'; + if (code !== 'preflight_denied') return code; + // Match the exact human-readable strings `preflight` (engine/tools/ssh.ts) + // produces for each denial reason. Order matters: 'disabled' and 'locked' + // are checked before the generic access-denied so a disabled/locked + // connection isn't mislabeled as no_grant. + const msg = (result.message ?? '').toLowerCase(); + if (msg.includes('does not exist')) return 'connection_not_found'; + if (msg.includes('is disabled')) return 'connection_disabled'; + if (msg.includes('temporarily locked')) return 'abuse_locked'; + if (msg.includes('access denied')) return 'no_grant'; + return 'preflight_denied'; +} + +/** + * REST router exposing POST /local/tasks/:taskId/console/session. + * + * Lets a user open an SSH console PTY session themselves from a task's + * Console tab. The session is keyed by localTaskId, so the WS/xterm and the + * AI console tools share it automatically. The handler runs the SAME gate as + * the agent-facing SshConsoleEnsure tool: it calls the shared + * `openConsoleSession` core, which runs the full preflight (piece membership + * / access decision / enabled / abuse / host-key) against the task's piece + * name. `allowedConnections: ['*']` is passed because the per-piece + * allowed-list is an agent-prompt concept; the authoritative gate is the + * access resolver against `task.pieceName`, which still runs inside the core. + */ +export function createConsoleSessionRouter(deps: { + sub: OpenConsoleDeps['sub']; + preflight: OpenConsoleDeps['preflight']; + requireAuth: any; + resolveTask: (taskId: string, user: SimpleUser) => Promise; +}): Router { + const r = Router(); + r.post( + '/local/tasks/:taskId/console/session', + deps.requireAuth, + async (req: Request, res: Response) => { + const taskId = req.params.taskId!; + const user = (req.user as SimpleUser | undefined) ?? null; + if (!user) { + res.status(401).json({ error: 'unauthenticated' }); + return; + } + + const task = await deps.resolveTask(taskId, user); + if (!task) { + // Missing OR not visible to this user → opaque 404 (don't leak + // task existence across the visibility boundary). + res.status(404).json({ error: 'task_not_found' }); + return; + } + + const body = (req.body ?? {}) as { + connection_id?: unknown; + cols?: unknown; + rows?: unknown; + force_replace?: unknown; + }; + const connectionId = typeof body.connection_id === 'string' ? body.connection_id : ''; + if (!connectionId) { + res.status(400).json({ error: 'missing_connection_id' }); + return; + } + const cols = typeof body.cols === 'number' ? body.cols : undefined; + const rows = typeof body.rows === 'number' ? body.rows : undefined; + + const result = await openConsoleSession( + { sub: deps.sub, preflight: deps.preflight }, + { + taskId, + connectionId, + ownerId: task.ownerId || 'local', + userId: user.id, + pieceName: task.pieceName, + // Per-piece allowed-list is an agent-prompt concept; the real gate + // is the access resolver against task.pieceName inside the core. + allowedConnections: ['*'], + cols, + rows, + forceReplace: body.force_replace === true, + initiator: 'user', + }, + ); + + if (result.ok) { + res.status(200).json({ + ok: true, + connection_id: result.connectionId, + cols: result.cols, + rows: result.rows, + ...(result.alreadyActive ? { already_active: true } : {}), + }); + return; + } + + const code = refineErrorCode(result); + res.status(statusForOpenError(code)).json({ error: code }); + }, + ); + return r; +} diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 61fbfd8..df49133 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -88,11 +88,12 @@ import { } from '../ssh/session.js'; import { SessionRegistry } from '../ssh/console-registry.js'; import { createSshUserRouter, createSshAdminRouter, type SshApiDeps } from './ssh-api.js'; -import { setSshSubsystem } from '../engine/tools/ssh.js'; +import { setSshSubsystem, preflight as sshPreflight, type SshSubsystem } from '../engine/tools/ssh.js'; import { __setActiveSessionLookup } from '../engine/agent-loop.js'; import { attachConsoleWs, createConsoleStatusRouter, + createConsoleSessionRouter, type SimpleTask, type SimpleUser, } from './console-ws-api.js'; @@ -629,7 +630,11 @@ export function createCoreServer(opts: CoreServerOptions): { // SshDownload tools can access the same repos / session primitives / // crypto wrappers that the HTTP layer uses. sessionRegistry is // constructed above (hoisted so sshDeps.onAccessRevoked can use it). - setSshSubsystem({ + // Captured in a local const (not just passed to setSshSubsystem) + // so the user-initiated console-session REST endpoint can call the + // shared openConsoleSession core with the EXACT same `sub` the + // agent-facing console tools use — no second SshSubsystem. + const sshSubsystem: SshSubsystem = { connectionRepo, auditRepo, abuseRepo, @@ -651,7 +656,8 @@ export function createCoreServer(opts: CoreServerOptions): { config: sshConfig, sessionRegistry, openShellChannel, - }); + }; + setSshSubsystem(sshSubsystem); // Phase 4 (SSH Console): wire the registry into agent-loop so // buildSystemPrompt can auto-inject the live screen tail into @@ -751,6 +757,21 @@ export function createCoreServer(opts: CoreServerOptions): { }), ); + // REST user-initiated session-open endpoint: + // POST /api/local/tasks/:taskId/console/session. Reuses the same + // SshSubsystem + preflight the console tools use; the access gate + // runs inside openConsoleSession against task.pieceName. + app.use( + '/api', + express.json(), + createConsoleSessionRouter({ + sub: sshSubsystem, + preflight: sshPreflight, + requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(), + resolveTask: consoleDeps.resolveTask, + }), + ); + // Phase 6 (SSH Console): admin list + kill endpoints. The // `/api/admin` prefix already has `express.json()` mounted above // (see Admin user management API), so POST bodies parse correctly. diff --git a/src/engine/tools/ssh-console.ts b/src/engine/tools/ssh-console.ts index e7abf57..8c7c2b5 100644 --- a/src/engine/tools/ssh-console.ts +++ b/src/engine/tools/ssh-console.ts @@ -32,6 +32,319 @@ import { logger } from '../../logger.js'; import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js'; import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js'; +// ────────────────────────────────────────────────────────────────────── +// openConsoleSession — the shared find-or-open core +// ────────────────────────────────────────────────────────────────────── +// +// Extracted from the SshConsoleEnsure tool so a future HTTP endpoint can +// create a session WITHOUT fabricating a ToolContext. The tool wrapper +// (ensureSessionInternal) builds OpenConsoleParams from its ToolContext +// (initiator: 'agent') and maps the structured result back to the tool's +// existing return/error shape. Every preflight gate, host-key check, +// disabled/abuse check, key decryption, shell-channel open, session +// register, per-connection cap, and the `ssh.console.open` audit are +// preserved verbatim — in the same order, with the same semantics. + +/** Explicit collaborators the find-or-open core needs. `sub` carries the + * sessionRegistry, connectionRepo, access resolver, key decryptors, audit + * repo, abuse repo, config (cols/rows defaults + caps) and openShellChannel. + * `preflight` is threaded explicitly (it is the shared access/state gate). */ +export interface OpenConsoleDeps { + sub: SshSubsystem; + preflight: typeof preflight; +} + +export interface OpenConsoleParams { + taskId: string; + connectionId: string; + /** Connection-resolution / audit owner (job.ownerId ?? 'local' upstream). */ + ownerId: string | null; + /** The acting principal (startedByUserId). */ + userId: string; + /** For the grant check (access resolver matches against this piece). */ + pieceName: string; + /** Piece allowed_ssh_connections (['*'] for user-initiated). */ + allowedConnections: string[]; + cols?: number; + rows?: number; + forceReplace?: boolean; + /** Audit marker — distinguishes human-opened from agent-opened sessions. */ + initiator: 'agent' | 'user'; + /** Optional job id for the audit rows (agent path passes ctx.jobId). */ + jobId?: string | null; +} + +export interface OpenConsoleResult { + ok: boolean; + /** True if a live session already existed and was reused (no open). */ + alreadyActive?: boolean; + connectionId?: string; + cols?: number; + rows?: number; + /** Structured error code on failure. */ + error?: + | 'no_grant' + | 'host_key_not_verified' + | 'host_key_mismatch' + | 'connection_change_requires_force' + | 'abuse_locked' + | 'connection_disabled' + | 'connection_not_found' + | 'missing_connection_id' + | 'missing_task_context' + | 'no_user_context' + | 'piece_not_configured' + | 'piece_not_allowed' + | 'preflight_denied' + | 'decrypt_failed' + | 'open_shell_failed'; + /** Human/LLM-readable message (mirrors the tool's existing error strings). */ + message?: string; + /** The live session on success (consumed by the tool wrapper; not serialized). */ + session?: ConsoleSession; +} + +/** + * Find-or-open a ConsoleSession bound to (taskId, connectionId). Runs the + * full preflight (piece membership, access decision, enabled / abuse / + * host-key state), decrypts key material, opens the shell channel, builds + + * registers the ConsoleSession, enforces the per-connection cap, and writes + * the `ssh.console.open` audit (with `initiator` in the detail). + * + * Returns a structured OpenConsoleResult. On the happy path / reuse, + * `ok: true` with `session` set; on any gate failure, `ok: false` with a + * structured `error` code + `message`. + */ +export async function openConsoleSession( + deps: OpenConsoleDeps, + params: OpenConsoleParams, +): Promise { + const { sub } = deps; + const connectionId = params.connectionId; + if (!connectionId) { + return { ok: false, error: 'missing_connection_id', message: 'SshConsoleEnsure: connection_id is required.' }; + } + const localTaskId = params.taskId ?? ''; + if (!localTaskId) { + return { + ok: false, + error: 'missing_task_context', + message: 'SshConsoleEnsure: this tool requires a local task context (ctx.taskId).', + }; + } + + // If a session already exists for this task, branch on whether it's the + // same connection. Same → reuse. Different → reject by default (so a + // single LLM connection_id slip can't kill the user's live shell), opt + // into the swap with force_replace=true. + const existing = sub.sessionRegistry.get(localTaskId); + if (existing) { + if (existing.connectionId === connectionId) { + return { + ok: true, + alreadyActive: true, + connectionId: existing.connectionId, + cols: existing.cols, + rows: existing.rows, + session: existing, + }; + } + const forceReplace = params.forceReplace === true; + if (!forceReplace) { + const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000)); + const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000)); + return { + ok: false, + error: 'connection_change_requires_force', + connectionId: existing.connectionId, + message: + `SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` + + `(age=${ageSec}s, last_activity=${idleSec}s ago). ` + + `Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` + + `or pass force_replace=true to close it and open a new session on ${connectionId}.`, + }; + } + await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change'); + } + + // Build the minimal ToolContext-shaped object the shared `preflight` + // helper reads (userId, ownerId, pieceName, allowedSshConnections, jobId). + // The HTTP caller never constructs a ToolContext — params are explicit and + // this synthesis is internal to the shared core. + const preCtx = { + workspacePath: '', + editAllowed: false, + taskId: localTaskId, + userId: params.userId, + ownerId: params.ownerId, + jobId: params.jobId ?? undefined, + pieceName: params.pieceName, + allowedSshConnections: params.allowedConnections, + } as ToolContext; + + // Full 12-step preflight (same path as SshExec). + const pre = deps.preflight({ + toolName: 'SshExec', + connectionId, + ctx: preCtx, + sub, + auditAction: 'ssh.console.open', + }); + if (!pre.ok) { + return { + ok: false, + error: 'preflight_denied', + message: pre.error.output, + }; + } + const { connection, actingUserId, pieceName } = pre; + + // Console requires a verified host key — there is no LLM-actionable + // recovery from first_observe / mismatch on a long-lived shell. + if (connection.hostKeyVerifiedAt === null) { + sub.auditRepo.beginAndComplete( + { + action: 'ssh.console.open', + connectionId, + ownerId: connection.ownerId, + actingUserId, + pieceName, + jobId: preCtx.jobId ?? undefined, + detail: { reason: 'host_key_not_verified', initiator: params.initiator }, + }, + 'denied', + ); + return { + ok: false, + error: 'host_key_not_verified', + message: `SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`, + }; + } + + const cols = typeof params.cols === 'number' && params.cols > 0 ? Math.floor(params.cols) : sub.config.console.defaultCols; + const rows = typeof params.rows === 'number' && params.rows > 0 ? Math.floor(params.rows) : sub.config.console.defaultRows; + + // Decrypt key material — same flow as SshExec; we clear on failure but + // keep alive past this call because the ssh2 Client needs the PEM through + // the entire session. ConsoleSession.close() does NOT clear these + // buffers (it can't see them) — we accept that the PEM stays in memory + // for the lifetime of the session, which already holds the decrypted + // channel and host connection state. + let pemBuf: Buffer | null = null; + let passBuf: Buffer | null = null; + try { + pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc); + passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc); + } catch (e) { + if (pemBuf) clearBuffer(pemBuf); + if (passBuf) clearBuffer(passBuf); + sub.auditRepo.beginAndComplete( + { + action: 'ssh.console.open', + connectionId, + ownerId: connection.ownerId, + actingUserId, + pieceName, + jobId: preCtx.jobId ?? undefined, + detail: { reason: 'decrypt_failed', msg: (e as Error).message, initiator: params.initiator }, + }, + 'failed', + ); + return { ok: false, error: 'decrypt_failed', message: 'SshConsoleEnsure: failed to decrypt stored key material.' }; + } + + // Open the channel. On failure clear the PEM and bail. + let channel: import('ssh2').ClientChannel; + let client: import('ssh2').Client; + let hostFingerprint: string; + try { + const shellResult = await sub.openShellChannel({ + connection: { + id: connection.id, + ownerId: connection.ownerId, + host: connection.host, + port: connection.port, + username: connection.username, + privateKeyPem: pemBuf, + passphrase: passBuf ?? undefined, + hostKeyB64: connection.hostKeyB64, + hostKeyVerified: true, + allowPrivate: sub.config.allowPrivateAddresses || connection.allowPrivateAddresses, + }, + cols, + rows, + timeoutMs: sub.config.callTimeoutSeconds * 1000, + }); + channel = shellResult.channel; + client = shellResult.client; + hostFingerprint = shellResult.hostFingerprint; + } catch (e) { + clearBuffer(pemBuf); + clearBuffer(passBuf); + sub.abuseRepo.checkAndRecordFailure({ + connectionId, + ownerId: connection.ownerId, + userId: actingUserId, + host: connection.host, + username: connection.username, + }); + sub.auditRepo.beginAndComplete( + { + action: 'ssh.console.open', + connectionId, + ownerId: connection.ownerId, + actingUserId, + pieceName, + jobId: preCtx.jobId ?? undefined, + detail: { reason: 'open_shell_failed', msg: (e as Error).message, initiator: params.initiator }, + }, + 'failed', + ); + return { ok: false, error: 'open_shell_failed', message: `SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}` }; + } + + // Build the session and register it. From here on the channel + client + // + PEM belong to the session; we don't clear them on the happy path. + // The session ends the client (and thus releases the PEM-bound + // connection) when it closes. + const session = new ConsoleSession({ + localTaskId, + connectionId, + ownerId: connection.ownerId, + startedByUserId: actingUserId, + cols, + rows, + scrollbackCap: sub.config.console.scrollbackBytes, + channel, + client, + auditRepo: sub.auditRepo, + }); + sub.sessionRegistry.register(session); + sub.abuseRepo.recordSuccess(connectionId); + sub.auditRepo.beginAndComplete( + { + action: 'ssh.console.open', + connectionId, + ownerId: connection.ownerId, + actingUserId, + pieceName, + jobId: preCtx.jobId ?? undefined, + detail: { cols, rows, host_fingerprint: hostFingerprint, initiator: params.initiator }, + }, + 'success', + ); + + // Enforce the per-connection session cap (evict oldest). + const evict = sub.sessionRegistry.enforceCap(connectionId); + for (const e of evict) { + sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) => + logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`), + ); + } + + return { ok: true, alreadyActive: false, connectionId, cols, rows, session }; +} + // ────────────────────────────────────────────────────────────────────── // Tool definitions // ────────────────────────────────────────────────────────────────────── @@ -167,6 +480,11 @@ interface EnsureResult { * Internal find-or-open helper. Returns a live ConsoleSession bound to * (ctx.taskId, connectionId). Used by SshConsoleEnsure directly and by * SshConsoleSend / SshConsoleSnapshot when no session is attached yet. + * + * Thin wrapper over the shared `openConsoleSession` core: it builds + * OpenConsoleParams from the ToolContext (initiator: 'agent') and maps the + * structured result back to the tool's existing return/error shape so the + * tool's external behavior is byte-for-byte unchanged. */ async function ensureSessionInternal( input: Record, @@ -174,190 +492,30 @@ async function ensureSessionInternal( sub: SshSubsystem, ): Promise { const connectionId = typeof input.connection_id === 'string' ? input.connection_id : ''; - if (!connectionId) { - return err('SshConsoleEnsure: connection_id is required.'); - } - const localTaskId = ctx.taskId ?? ''; - if (!localTaskId) { - return err('SshConsoleEnsure: this tool requires a local task context (ctx.taskId).'); - } + const cols = typeof input.cols === 'number' ? input.cols : undefined; + const rows = typeof input.rows === 'number' ? input.rows : undefined; - // If a session already exists for this task, branch on whether it's the - // same connection. Same → reuse. Different → reject by default (so a - // single LLM connection_id slip can't kill the user's live shell), opt - // into the swap with force_replace=true. - const existing = sub.sessionRegistry.get(localTaskId); - if (existing) { - if (existing.connectionId === connectionId) { - return { opened: false, session: existing }; - } - const forceReplace = input.force_replace === true; - if (!forceReplace) { - const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000)); - const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000)); - return err( - `SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` + - `(age=${ageSec}s, last_activity=${idleSec}s ago). ` + - `Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` + - `or pass force_replace=true to close it and open a new session on ${connectionId}.`, - ); - } - await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change'); - } - - // Full 12-step preflight (same path as SshExec). - const pre = preflight({ - toolName: 'SshExec', - connectionId, - ctx, - sub, - auditAction: 'ssh.console.open', - }); - if (!pre.ok) return pre.error; - const { connection, actingUserId, pieceName } = pre; - - // Console requires a verified host key — there is no LLM-actionable - // recovery from first_observe / mismatch on a long-lived shell. - if (connection.hostKeyVerifiedAt === null) { - sub.auditRepo.beginAndComplete( - { - action: 'ssh.console.open', - connectionId, - ownerId: connection.ownerId, - actingUserId, - pieceName, - jobId: ctx.jobId ?? undefined, - detail: { reason: 'host_key_not_verified' }, - }, - 'denied', - ); - return err( - `SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`, - ); - } - - const cols = typeof input.cols === 'number' && input.cols > 0 ? Math.floor(input.cols) : sub.config.console.defaultCols; - const rows = typeof input.rows === 'number' && input.rows > 0 ? Math.floor(input.rows) : sub.config.console.defaultRows; - - // Decrypt key material — same flow as SshExec; we clear on failure but - // keep alive past this call because the ssh2 Client needs the PEM through - // the entire session. ConsoleSession.close() does NOT clear these - // buffers (it can't see them) — we accept that the PEM stays in memory - // for the lifetime of the session, which already holds the decrypted - // channel and host connection state. - let pemBuf: Buffer | null = null; - let passBuf: Buffer | null = null; - try { - pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc); - passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc); - } catch (e) { - if (pemBuf) clearBuffer(pemBuf); - if (passBuf) clearBuffer(passBuf); - sub.auditRepo.beginAndComplete( - { - action: 'ssh.console.open', - connectionId, - ownerId: connection.ownerId, - actingUserId, - pieceName, - jobId: ctx.jobId ?? undefined, - detail: { reason: 'decrypt_failed', msg: (e as Error).message }, - }, - 'failed', - ); - return err('SshConsoleEnsure: failed to decrypt stored key material.'); - } - - // Open the channel. On failure clear the PEM and bail. - let channel: import('ssh2').ClientChannel; - let client: import('ssh2').Client; - let hostFingerprint: string; - try { - const shellResult = await sub.openShellChannel({ - connection: { - id: connection.id, - ownerId: connection.ownerId, - host: connection.host, - port: connection.port, - username: connection.username, - privateKeyPem: pemBuf, - passphrase: passBuf ?? undefined, - hostKeyB64: connection.hostKeyB64, - hostKeyVerified: true, - allowPrivate: sub.config.allowPrivateAddresses || connection.allowPrivateAddresses, - }, + const result = await openConsoleSession( + { sub, preflight }, + { + taskId: ctx.taskId ?? '', + connectionId, + ownerId: ctx.ownerId ?? null, + userId: (ctx.userId ?? ctx.ownerId ?? '').toString(), + pieceName: ctx.pieceName ?? '', + allowedConnections: ctx.allowedSshConnections ?? [], cols, rows, - timeoutMs: sub.config.callTimeoutSeconds * 1000, - }); - channel = shellResult.channel; - client = shellResult.client; - hostFingerprint = shellResult.hostFingerprint; - } catch (e) { - clearBuffer(pemBuf); - clearBuffer(passBuf); - sub.abuseRepo.checkAndRecordFailure({ - connectionId, - ownerId: connection.ownerId, - userId: actingUserId, - host: connection.host, - username: connection.username, - }); - sub.auditRepo.beginAndComplete( - { - action: 'ssh.console.open', - connectionId, - ownerId: connection.ownerId, - actingUserId, - pieceName, - jobId: ctx.jobId ?? undefined, - detail: { reason: 'open_shell_failed', msg: (e as Error).message }, - }, - 'failed', - ); - return err(`SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}`); - } - - // Build the session and register it. From here on the channel + client - // + PEM belong to the session; we don't clear them on the happy path. - // The session ends the client (and thus releases the PEM-bound - // connection) when it closes. - const session = new ConsoleSession({ - localTaskId, - connectionId, - ownerId: connection.ownerId, - startedByUserId: actingUserId, - cols, - rows, - scrollbackCap: sub.config.console.scrollbackBytes, - channel, - client, - auditRepo: sub.auditRepo, - }); - sub.sessionRegistry.register(session); - sub.abuseRepo.recordSuccess(connectionId); - sub.auditRepo.beginAndComplete( - { - action: 'ssh.console.open', - connectionId, - ownerId: connection.ownerId, - actingUserId, - pieceName, + forceReplace: input.force_replace === true, + initiator: 'agent', jobId: ctx.jobId ?? undefined, - detail: { cols, rows, host_fingerprint: hostFingerprint }, }, - 'success', ); - // Enforce the per-connection session cap (evict oldest). - const evict = sub.sessionRegistry.enforceCap(connectionId); - for (const e of evict) { - sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) => - logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`), - ); + if (!result.ok || !result.session) { + return err(result.message ?? 'SshConsoleEnsure: failed to open session.'); } - - return { opened: true, session }; + return { opened: !result.alreadyActive, session: result.session }; } async function ensureTool( diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e4a6fda..7a09d7d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ) : ( - setUrlState(prev => ({ ...prev, mobileTab: id }))} onSwipeRightFromEdge={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))} visibleTabs={mobileVisibleTabIds}> +
{mobileVisibleTabs.map(({ id, label }) => (
-
- {mobileTab === 'chat' && ( - chatReady ? ( - - ) : ( - - ) - )} - {mobileTab !== 'chat' && localTaskId && ( - setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })), - onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })), - })} - /> - )} +
+ 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') + ?
{id === 'browser' ? 'ブラウザ' : 'SSH'}
+ : id === 'chat' + ? (chatReady + ? + : ) + : (localTaskId + ? setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })), + onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })), + })} + /> + : null) + )} + />
{/* 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 (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 ( -
+
{children}
); diff --git a/ui/src/components/detail/tabs/ConsoleTab.tsx b/ui/src/components/detail/tabs/ConsoleTab.tsx index e492784..ac65026 100644 --- a/ui/src/components/detail/tabs/ConsoleTab.tsx +++ b/ui/src/components/detail/tabs/ConsoleTab.tsx @@ -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({ 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 (
- - {compactMode && } + {showPicker ? ( + { + // 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(); + }} + /> + ) : ( + <> + + {compactMode && } + + )} +
+ {compactMode && !showPicker && } +
+ ); +} + +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(''); + 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(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 ( +
+
+ SSH サブシステムは無効です。config.yaml の{' '} + ssh.enabled: true を設定後にサーバーを再起動してください。 +
+
+ ); + } + + return ( +
+
+
+

SSH コンソールを開始

+

+ 接続を選んでセッションを開始すると、ターミナルが開き AI とこのタスクで共有されます。 +

+
+ + {isLoading &&
Loading…
} + {error &&
読み込みに失敗しました: {String(error)}
} + + {!isLoading && connections.length === 0 ? ( +
+ 利用できる SSH 接続がありません — Settings → SSH Connections で登録/grant してください。 +
+ ) : ( + <> + + + + + {replaceCandidate && ( + + )} + + )} + + {errMsg && ( +
+ {errMsg.msg} +
+ )}
- {compactMode && }
); } diff --git a/ui/src/components/mobile/SwipeableTabs.tsx b/ui/src/components/mobile/SwipeableTabs.tsx new file mode 100644 index 0000000..80e1933 --- /dev/null +++ b/ui/src/components/mobile/SwipeableTabs.tsx @@ -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 { + /** 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
) 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({
+  tabs,
+  activeTab,
+  onTabChange,
+  onSwipeBackFromFirst,
+  renderTab,
+}: SwipeableTabsProps) {
+  const containerRef = useRef(null);
+  const activeRef = useRef(null);
+  const peekRef = useRef(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 (
+    
+
+ {renderTab(activeTab, false)} +
+ {peek && ( +
+ {renderTab(peek.tab, true)} +
+ )} +
+ ); +} diff --git a/ui/src/hooks/useConsoleSession.ts b/ui/src/hooks/useConsoleSession.ts index 7bc18de..13b97dc 100644 --- a/ui/src/hooks/useConsoleSession.ts +++ b/ui/src/hooks/useConsoleSession.ts @@ -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?.(); + }, }; } diff --git a/ui/src/hooks/useSwipeNav.ts b/ui/src/hooks/useSwipeNav.ts deleted file mode 100644 index f39cc39..0000000 --- a/ui/src/hooks/useSwipeNav.ts +++ /dev/null @@ -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 }); - *
...
- * - * 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) => { - 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) => { - 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 }; -} diff --git a/ui/src/lib/tab-swipe.test.ts b/ui/src/lib/tab-swipe.test.ts new file mode 100644 index 0000000..34f566c --- /dev/null +++ b/ui/src/lib/tab-swipe.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/tab-swipe.ts b/ui/src/lib/tab-swipe.ts new file mode 100644 index 0000000..3652d3c --- /dev/null +++ b/ui/src/lib/tab-swipe.ts @@ -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; +}